init starter package

This commit is contained in:
2025-10-16 17:29:57 +02:00
commit b7c67b5834
216 changed files with 37028 additions and 0 deletions
@@ -0,0 +1,30 @@
import { retrieveCart } from "@lib/data/cart"
import { retrieveCustomer } from "@lib/data/customer"
import PaymentWrapper from "@modules/checkout/components/payment-wrapper"
import CheckoutForm from "@modules/checkout/templates/checkout-form"
import CheckoutSummary from "@modules/checkout/templates/checkout-summary"
import { Metadata } from "next"
import { notFound } from "next/navigation"
export const metadata: Metadata = {
title: "Checkout",
}
export default async function Checkout() {
const cart = await retrieveCart()
if (!cart) {
return notFound()
}
const customer = await retrieveCustomer()
return (
<div className="grid grid-cols-1 small:grid-cols-[1fr_416px] content-container gap-x-40 py-12">
<PaymentWrapper cart={cart}>
<CheckoutForm cart={cart} customer={customer} />
</PaymentWrapper>
<CheckoutSummary cart={cart} />
</div>
)
}
@@ -0,0 +1,43 @@
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import ChevronDown from "@modules/common/icons/chevron-down"
import MedusaCTA from "@modules/layout/components/medusa-cta"
export default function CheckoutLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="w-full bg-white relative small:min-h-screen">
<div className="h-16 bg-white border-b ">
<nav className="flex h-full items-center content-container justify-between">
<LocalizedClientLink
href="/cart"
className="text-small-semi text-ui-fg-base flex items-center gap-x-2 uppercase flex-1 basis-0"
data-testid="back-to-cart-link"
>
<ChevronDown className="rotate-90" size={16} />
<span className="mt-px hidden small:block txt-compact-plus text-ui-fg-subtle hover:text-ui-fg-base ">
Back to shopping cart
</span>
<span className="mt-px block small:hidden txt-compact-plus text-ui-fg-subtle hover:text-ui-fg-base">
Back
</span>
</LocalizedClientLink>
<LocalizedClientLink
href="/"
className="txt-compact-xlarge-plus text-ui-fg-subtle hover:text-ui-fg-base uppercase"
data-testid="store-link"
>
Medusa Store
</LocalizedClientLink>
<div className="flex-1 basis-0" />
</nav>
</div>
<div className="relative" data-testid="checkout-container">{children}</div>
<div className="py-4 w-full flex items-center justify-center">
<MedusaCTA />
</div>
</div>
)
}
@@ -0,0 +1,19 @@
import InteractiveLink from "@modules/common/components/interactive-link"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "404",
description: "Something went wrong",
}
export default async function NotFound() {
return (
<div className="flex flex-col gap-4 items-center justify-center min-h-[calc(100vh-64px)]">
<h1 className="text-2xl-semi text-ui-fg-base">Page not found</h1>
<p className="text-small-regular text-ui-fg-base">
The page you tried to access does not exist.
</p>
<InteractiveLink href="/">Go to frontpage</InteractiveLink>
</div>
)
}
@@ -0,0 +1,38 @@
import { Metadata } from "next"
import { notFound } from "next/navigation"
import AddressBook from "@modules/account/components/address-book"
import { getRegion } from "@lib/data/regions"
import { retrieveCustomer } from "@lib/data/customer"
export const metadata: Metadata = {
title: "Addresses",
description: "View your addresses",
}
export default async function Addresses(props: {
params: Promise<{ countryCode: string }>
}) {
const params = await props.params
const { countryCode } = params
const customer = await retrieveCustomer()
const region = await getRegion(countryCode)
if (!customer || !region) {
notFound()
}
return (
<div className="w-full" data-testid="addresses-page-wrapper">
<div className="mb-8 flex flex-col gap-y-4">
<h1 className="text-2xl-semi">Shipping Addresses</h1>
<p className="text-base-regular">
View and update your shipping addresses, you can add as many as you
like. Saving your addresses will make them available during checkout.
</p>
</div>
<AddressBook customer={customer} region={region} />
</div>
)
}
@@ -0,0 +1,9 @@
import Spinner from "@modules/common/icons/spinner"
export default function Loading() {
return (
<div className="flex items-center justify-center w-full h-full text-ui-fg-base">
<Spinner size={36} />
</div>
)
}
@@ -0,0 +1,33 @@
import { retrieveOrder } from "@lib/data/orders"
import OrderDetailsTemplate from "@modules/order/templates/order-details-template"
import { Metadata } from "next"
import { notFound } from "next/navigation"
type Props = {
params: Promise<{ id: string }>
}
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params
const order = await retrieveOrder(params.id).catch(() => null)
if (!order) {
notFound()
}
return {
title: `Order #${order.display_id}`,
description: `View your order`,
}
}
export default async function OrderDetailPage(props: Props) {
const params = await props.params
const order = await retrieveOrder(params.id).catch(() => null)
if (!order) {
notFound()
}
return <OrderDetailsTemplate order={order} />
}
@@ -0,0 +1,37 @@
import { Metadata } from "next"
import OrderOverview from "@modules/account/components/order-overview"
import { notFound } from "next/navigation"
import { listOrders } from "@lib/data/orders"
import Divider from "@modules/common/components/divider"
import TransferRequestForm from "@modules/account/components/transfer-request-form"
export const metadata: Metadata = {
title: "Orders",
description: "Overview of your previous orders.",
}
export default async function Orders() {
const orders = await listOrders()
if (!orders) {
notFound()
}
return (
<div className="w-full" data-testid="orders-page-wrapper">
<div className="mb-8 flex flex-col gap-y-4">
<h1 className="text-2xl-semi">Orders</h1>
<p className="text-base-regular">
View your previous orders and their status. You can also create
returns or exchanges for your orders if needed.
</p>
</div>
<div>
<OrderOverview orders={orders} />
<Divider className="my-16" />
<TransferRequestForm />
</div>
</div>
)
}
@@ -0,0 +1,22 @@
import { Metadata } from "next"
import Overview from "@modules/account/components/overview"
import { notFound } from "next/navigation"
import { retrieveCustomer } from "@lib/data/customer"
import { listOrders } from "@lib/data/orders"
export const metadata: Metadata = {
title: "Account",
description: "Overview of your account activity.",
}
export default async function OverviewTemplate() {
const customer = await retrieveCustomer().catch(() => null)
const orders = (await listOrders().catch(() => null)) || null
if (!customer) {
notFound()
}
return <Overview customer={customer} orders={orders} />
}
@@ -0,0 +1,54 @@
import { Metadata } from "next"
import ProfilePhone from "@modules/account//components/profile-phone"
import ProfileBillingAddress from "@modules/account/components/profile-billing-address"
import ProfileEmail from "@modules/account/components/profile-email"
import ProfileName from "@modules/account/components/profile-name"
import ProfilePassword from "@modules/account/components/profile-password"
import { notFound } from "next/navigation"
import { listRegions } from "@lib/data/regions"
import { retrieveCustomer } from "@lib/data/customer"
export const metadata: Metadata = {
title: "Profile",
description: "View and edit your Medusa Store profile.",
}
export default async function Profile() {
const customer = await retrieveCustomer()
const regions = await listRegions()
if (!customer || !regions) {
notFound()
}
return (
<div className="w-full" data-testid="profile-page-wrapper">
<div className="mb-8 flex flex-col gap-y-4">
<h1 className="text-2xl-semi">Profile</h1>
<p className="text-base-regular">
View and update your profile information, including your name, email,
and phone number. You can also update your billing address, or change
your password.
</p>
</div>
<div className="flex flex-col gap-y-8 w-full">
<ProfileName customer={customer} />
<Divider />
<ProfileEmail customer={customer} />
<Divider />
<ProfilePhone customer={customer} />
<Divider />
{/* <ProfilePassword customer={customer} />
<Divider /> */}
<ProfileBillingAddress customer={customer} regions={regions} />
</div>
</div>
)
}
const Divider = () => {
return <div className="w-full h-px bg-gray-200" />
}
;``
@@ -0,0 +1,12 @@
import { Metadata } from "next"
import LoginTemplate from "@modules/account/templates/login-template"
export const metadata: Metadata = {
title: "Sign in",
description: "Sign in to your Medusa Store account.",
}
export default function Login() {
return <LoginTemplate />
}
@@ -0,0 +1,20 @@
import { retrieveCustomer } from "@lib/data/customer"
import { Toaster } from "@medusajs/ui"
import AccountLayout from "@modules/account/templates/account-layout"
export default async function AccountPageLayout({
dashboard,
login,
}: {
dashboard?: React.ReactNode
login?: React.ReactNode
}) {
const customer = await retrieveCustomer().catch(() => null)
return (
<AccountLayout customer={customer}>
{customer ? dashboard : login}
<Toaster />
</AccountLayout>
)
}
@@ -0,0 +1,9 @@
import Spinner from "@modules/common/icons/spinner"
export default function Loading() {
return (
<div className="flex items-center justify-center w-full h-full text-ui-fg-base">
<Spinner size={36} />
</div>
)
}
@@ -0,0 +1,5 @@
import SkeletonCartPage from "@modules/skeletons/templates/skeleton-cart-page"
export default function Loading() {
return <SkeletonCartPage />
}
@@ -0,0 +1,21 @@
import { Metadata } from "next"
import InteractiveLink from "@modules/common/components/interactive-link"
export const metadata: Metadata = {
title: "404",
description: "Something went wrong",
}
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-64px)]">
<h1 className="text-2xl-semi text-ui-fg-base">Page not found</h1>
<p className="text-small-regular text-ui-fg-base">
The cart you tried to access does not exist. Clear your cookies and try
again.
</p>
<InteractiveLink href="/">Go to frontpage</InteractiveLink>
</div>
)
}
@@ -0,0 +1,21 @@
import { retrieveCart } from "@lib/data/cart"
import { retrieveCustomer } from "@lib/data/customer"
import CartTemplate from "@modules/cart/templates"
import { Metadata } from "next"
import { notFound } from "next/navigation"
export const metadata: Metadata = {
title: "Cart",
description: "View your cart",
}
export default async function Cart() {
const cart = await retrieveCart().catch((error) => {
console.error(error)
return notFound()
})
const customer = await retrieveCustomer()
return <CartTemplate cart={cart} customer={customer} />
}
@@ -0,0 +1,85 @@
import { Metadata } from "next"
import { notFound } from "next/navigation"
import { getCategoryByHandle, listCategories } from "@lib/data/categories"
import { listRegions } from "@lib/data/regions"
import { StoreRegion } from "@medusajs/types"
import CategoryTemplate from "@modules/categories/templates"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
type Props = {
params: Promise<{ category: string[]; countryCode: string }>
searchParams: Promise<{
sortBy?: SortOptions
page?: string
}>
}
export async function generateStaticParams() {
const product_categories = await listCategories()
if (!product_categories) {
return []
}
const countryCodes = await listRegions().then((regions: StoreRegion[]) =>
regions?.map((r) => r.countries?.map((c) => c.iso_2)).flat()
)
const categoryHandles = product_categories.map(
(category: any) => category.handle
)
const staticParams = countryCodes
?.map((countryCode: string | undefined) =>
categoryHandles.map((handle: any) => ({
countryCode,
category: [handle],
}))
)
.flat()
return staticParams
}
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params
try {
const productCategory = await getCategoryByHandle(params.category)
const title = productCategory.name + " | Medusa Store"
const description = productCategory.description ?? `${title} category.`
return {
title: `${title} | Medusa Store`,
description,
alternates: {
canonical: `${params.category.join("/")}`,
},
}
} catch (error) {
notFound()
}
}
export default async function CategoryPage(props: Props) {
const searchParams = await props.searchParams
const params = await props.params
const { sortBy, page } = searchParams
const productCategory = await getCategoryByHandle(params.category)
if (!productCategory) {
notFound()
}
return (
<CategoryTemplate
category={productCategory}
sortBy={sortBy}
page={page}
countryCode={params.countryCode}
/>
)
}
@@ -0,0 +1,90 @@
import { Metadata } from "next"
import { notFound } from "next/navigation"
import { getCollectionByHandle, listCollections } from "@lib/data/collections"
import { listRegions } from "@lib/data/regions"
import { StoreCollection, StoreRegion } from "@medusajs/types"
import CollectionTemplate from "@modules/collections/templates"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
type Props = {
params: Promise<{ handle: string; countryCode: string }>
searchParams: Promise<{
page?: string
sortBy?: SortOptions
}>
}
export const PRODUCT_LIMIT = 12
export async function generateStaticParams() {
const { collections } = await listCollections({
fields: "*products",
})
if (!collections) {
return []
}
const countryCodes = await listRegions().then(
(regions: StoreRegion[]) =>
regions
?.map((r) => r.countries?.map((c) => c.iso_2))
.flat()
.filter(Boolean) as string[]
)
const collectionHandles = collections.map(
(collection: StoreCollection) => collection.handle
)
const staticParams = countryCodes
?.map((countryCode: string) =>
collectionHandles.map((handle: string | undefined) => ({
countryCode,
handle,
}))
)
.flat()
return staticParams
}
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params
const collection = await getCollectionByHandle(params.handle)
if (!collection) {
notFound()
}
const metadata = {
title: `${collection.title} | Medusa Store`,
description: `${collection.title} collection`,
} as Metadata
return metadata
}
export default async function CollectionPage(props: Props) {
const searchParams = await props.searchParams
const params = await props.params
const { sortBy, page } = searchParams
const collection = await getCollectionByHandle(params.handle).then(
(collection: StoreCollection) => collection
)
if (!collection) {
notFound()
}
return (
<CollectionTemplate
collection={collection}
page={page}
sortBy={sortBy}
countryCode={params.countryCode}
/>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { Metadata } from "next"
import { listCartOptions, retrieveCart } from "@lib/data/cart"
import { retrieveCustomer } from "@lib/data/customer"
import { getBaseURL } from "@lib/util/env"
import { StoreCartShippingOption } from "@medusajs/types"
import CartMismatchBanner from "@modules/layout/components/cart-mismatch-banner"
import Footer from "@modules/layout/templates/footer"
import Nav from "@modules/layout/templates/nav"
import FreeShippingPriceNudge from "@modules/shipping/components/free-shipping-price-nudge"
export const metadata: Metadata = {
metadataBase: new URL(getBaseURL()),
}
export default async function PageLayout(props: { children: React.ReactNode }) {
const customer = await retrieveCustomer()
const cart = await retrieveCart()
let shippingOptions: StoreCartShippingOption[] = []
if (cart) {
const { shipping_options } = await listCartOptions()
shippingOptions = shipping_options
}
return (
<>
<Nav />
{customer && cart && (
<CartMismatchBanner customer={customer} cart={cart} />
)}
{cart && (
<FreeShippingPriceNudge
variant="popup"
cart={cart}
shippingOptions={shippingOptions}
/>
)}
{props.children}
<Footer />
</>
)
}
@@ -0,0 +1,20 @@
import { Metadata } from "next"
import InteractiveLink from "@modules/common/components/interactive-link"
export const metadata: Metadata = {
title: "404",
description: "Something went wrong",
}
export default function NotFound() {
return (
<div className="flex flex-col gap-4 items-center justify-center min-h-[calc(100vh-64px)]">
<h1 className="text-2xl-semi text-ui-fg-base">Page not found</h1>
<p className="text-small-regular text-ui-fg-base">
The page you tried to access does not exist.
</p>
<InteractiveLink href="/">Go to frontpage</InteractiveLink>
</div>
)
}
@@ -0,0 +1,5 @@
import SkeletonOrderConfirmed from "@modules/skeletons/templates/skeleton-order-confirmed"
export default function Loading() {
return <SkeletonOrderConfirmed />
}
@@ -0,0 +1,23 @@
import { retrieveOrder } from "@lib/data/orders"
import OrderCompletedTemplate from "@modules/order/templates/order-completed-template"
import { Metadata } from "next"
import { notFound } from "next/navigation"
type Props = {
params: Promise<{ id: string }>
}
export const metadata: Metadata = {
title: "Order Confirmed",
description: "You purchase was successful",
}
export default async function OrderConfirmedPage(props: Props) {
const params = await props.params
const order = await retrieveOrder(params.id).catch(() => null)
if (!order) {
return notFound()
}
return <OrderCompletedTemplate order={order} />
}
@@ -0,0 +1,41 @@
import { acceptTransferRequest } from "@lib/data/orders"
import { Heading, Text } from "@medusajs/ui"
import TransferImage from "@modules/order/components/transfer-image"
export default async function TransferPage({
params,
}: {
params: { id: string; token: string }
}) {
const { id, token } = params
const { success, error } = await acceptTransferRequest(id, token)
return (
<div className="flex flex-col gap-y-4 items-start w-2/5 mx-auto mt-10 mb-20">
<TransferImage />
<div className="flex flex-col gap-y-6">
{success && (
<>
<Heading level="h1" className="text-xl text-zinc-900">
Order transfered!
</Heading>
<Text className="text-zinc-600">
Order {id} has been successfully transfered to the new owner.
</Text>
</>
)}
{!success && (
<>
<Text className="text-zinc-600">
There was an error accepting the transfer. Please try again.
</Text>
{error && (
<Text className="text-red-500">Error message: {error}</Text>
)}
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,41 @@
import { declineTransferRequest } from "@lib/data/orders"
import { Heading, Text } from "@medusajs/ui"
import TransferImage from "@modules/order/components/transfer-image"
export default async function TransferPage({
params,
}: {
params: { id: string; token: string }
}) {
const { id, token } = params
const { success, error } = await declineTransferRequest(id, token)
return (
<div className="flex flex-col gap-y-4 items-start w-2/5 mx-auto mt-10 mb-20">
<TransferImage />
<div className="flex flex-col gap-y-6">
{success && (
<>
<Heading level="h1" className="text-xl text-zinc-900">
Order transfer declined!
</Heading>
<Text className="text-zinc-600">
Transfer of order {id} has been successfully declined.
</Text>
</>
)}
{!success && (
<>
<Text className="text-zinc-600">
There was an error declining the transfer. Please try again.
</Text>
{error && (
<Text className="text-red-500">Error message: {error}</Text>
)}
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,38 @@
import { Heading, Text } from "@medusajs/ui"
import TransferActions from "@modules/order/components/transfer-actions"
import TransferImage from "@modules/order/components/transfer-image"
export default async function TransferPage({
params,
}: {
params: { id: string; token: string }
}) {
const { id, token } = params
return (
<div className="flex flex-col gap-y-4 items-start w-2/5 mx-auto mt-10 mb-20">
<TransferImage />
<div className="flex flex-col gap-y-6">
<Heading level="h1" className="text-xl text-zinc-900">
Transfer request for order {id}
</Heading>
<Text className="text-zinc-600">
You&#39;ve received a request to transfer ownership of your order ({id}).
If you agree to this request, you can approve the transfer by clicking
the button below.
</Text>
<div className="w-full h-px bg-zinc-200" />
<Text className="text-zinc-600">
If you accept, the new owner will take over all responsibilities and
permissions associated with this order.
</Text>
<Text className="text-zinc-600">
If you do not recognize this request or wish to retain ownership, no
further action is required.
</Text>
<div className="w-full h-px bg-zinc-200" />
<TransferActions id={id} token={token} />
</div>
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { Metadata } from "next"
import FeaturedProducts from "@modules/home/components/featured-products"
import Hero from "@modules/home/components/hero"
import { listCollections } from "@lib/data/collections"
import { getRegion } from "@lib/data/regions"
export const metadata: Metadata = {
title: "Medusa Next.js Starter Template",
description:
"A performant frontend ecommerce starter template with Next.js 15 and Medusa.",
}
export default async function Home(props: {
params: Promise<{ countryCode: string }>
}) {
const params = await props.params
const { countryCode } = params
const region = await getRegion(countryCode)
const { collections } = await listCollections({
fields: "id, handle, title",
})
if (!collections || !region) {
return null
}
return (
<>
<Hero />
<div className="py-12">
<ul className="flex flex-col gap-x-6">
<FeaturedProducts collections={collections} region={region} />
</ul>
</div>
</>
)
}
@@ -0,0 +1,106 @@
import { Metadata } from "next"
import { notFound } from "next/navigation"
import { listProducts } from "@lib/data/products"
import { getRegion, listRegions } from "@lib/data/regions"
import ProductTemplate from "@modules/products/templates"
type Props = {
params: Promise<{ countryCode: string; handle: string }>
}
export async function generateStaticParams() {
try {
const countryCodes = await listRegions().then((regions) =>
regions?.map((r) => r.countries?.map((c) => c.iso_2)).flat()
)
if (!countryCodes) {
return []
}
const promises = countryCodes.map(async (country) => {
const { response } = await listProducts({
countryCode: country,
queryParams: { limit: 100, fields: "handle" },
})
return {
country,
products: response.products,
}
})
const countryProducts = await Promise.all(promises)
return countryProducts
.flatMap((countryData) =>
countryData.products.map((product) => ({
countryCode: countryData.country,
handle: product.handle,
}))
)
.filter((param) => param.handle)
} catch (error) {
console.error(
`Failed to generate static paths for product pages: ${
error instanceof Error ? error.message : "Unknown error"
}.`
)
return []
}
}
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params
const { handle } = params
const region = await getRegion(params.countryCode)
if (!region) {
notFound()
}
const product = await listProducts({
countryCode: params.countryCode,
queryParams: { handle },
}).then(({ response }) => response.products[0])
if (!product) {
notFound()
}
return {
title: `${product.title} | Medusa Store`,
description: `${product.title}`,
openGraph: {
title: `${product.title} | Medusa Store`,
description: `${product.title}`,
images: product.thumbnail ? [product.thumbnail] : [],
},
}
}
export default async function ProductPage(props: Props) {
const params = await props.params
const region = await getRegion(params.countryCode)
if (!region) {
notFound()
}
const pricedProduct = await listProducts({
countryCode: params.countryCode,
queryParams: { handle: params.handle },
}).then(({ response }) => response.products[0])
if (!pricedProduct) {
notFound()
}
return (
<ProductTemplate
product={pricedProduct}
region={region}
countryCode={params.countryCode}
/>
)
}
@@ -0,0 +1,33 @@
import { Metadata } from "next"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
import StoreTemplate from "@modules/store/templates"
export const metadata: Metadata = {
title: "Store",
description: "Explore all of our products.",
}
type Params = {
searchParams: Promise<{
sortBy?: SortOptions
page?: string
}>
params: Promise<{
countryCode: string
}>
}
export default async function StorePage(props: Params) {
const params = await props.params;
const searchParams = await props.searchParams;
const { sortBy, page } = searchParams
return (
<StoreTemplate
sortBy={sortBy}
page={page}
countryCode={params.countryCode}
/>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { getBaseURL } from "@lib/util/env"
import { Metadata } from "next"
import "styles/globals.css"
export const metadata: Metadata = {
metadataBase: new URL(getBaseURL()),
}
export default function RootLayout(props: { children: React.ReactNode }) {
return (
<html lang="en" data-mode="light">
<body>
<main className="relative">{props.children}</main>
</body>
</html>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { ArrowUpRightMini } from "@medusajs/icons"
import { Text } from "@medusajs/ui"
import { Metadata } from "next"
import Link from "next/link"
export const metadata: Metadata = {
title: "404",
description: "Something went wrong",
}
export default function NotFound() {
return (
<div className="flex flex-col gap-4 items-center justify-center min-h-[calc(100vh-64px)]">
<h1 className="text-2xl-semi text-ui-fg-base">Page not found</h1>
<p className="text-small-regular text-ui-fg-base">
The page you tried to access does not exist.
</p>
<Link
className="flex gap-x-1 items-center group"
href="/"
>
<Text className="text-ui-fg-interactive">Go to frontpage</Text>
<ArrowUpRightMini
className="group-hover:rotate-45 ease-in-out duration-150"
color="var(--fg-interactive)"
/>
</Link>
</div>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

+14
View File
@@ -0,0 +1,14 @@
import Medusa from "@medusajs/js-sdk"
// Defaults to standard port for Medusa server
let MEDUSA_BACKEND_URL = "http://localhost:9000"
if (process.env.MEDUSA_BACKEND_URL) {
MEDUSA_BACKEND_URL = process.env.MEDUSA_BACKEND_URL
}
export const sdk = new Medusa({
baseUrl: MEDUSA_BACKEND_URL,
debug: process.env.NODE_ENV === "development",
publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,
})
+68
View File
@@ -0,0 +1,68 @@
import React from "react"
import { CreditCard } from "@medusajs/icons"
import Ideal from "@modules/common/icons/ideal"
import Bancontact from "@modules/common/icons/bancontact"
import PayPal from "@modules/common/icons/paypal"
/* Map of payment provider_id to their title and icon. Add in any payment providers you want to use. */
export const paymentInfoMap: Record<
string,
{ title: string; icon: React.JSX.Element }
> = {
pp_stripe_stripe: {
title: "Credit card",
icon: <CreditCard />,
},
"pp_stripe-ideal_stripe": {
title: "iDeal",
icon: <Ideal />,
},
"pp_stripe-bancontact_stripe": {
title: "Bancontact",
icon: <Bancontact />,
},
pp_paypal_paypal: {
title: "PayPal",
icon: <PayPal />,
},
pp_system_default: {
title: "Manual Payment",
icon: <CreditCard />,
},
// Add more payment providers here
}
// This only checks if it is native stripe for card payments, it ignores the other stripe-based providers
export const isStripe = (providerId?: string) => {
return providerId?.startsWith("pp_stripe_")
}
export const isPaypal = (providerId?: string) => {
return providerId?.startsWith("pp_paypal")
}
export const isManual = (providerId?: string) => {
return providerId?.startsWith("pp_system_default")
}
// Add currencies that don't need to be divided by 100
export const noDivisionCurrencies = [
"krw",
"jpy",
"vnd",
"clp",
"pyg",
"xaf",
"xof",
"bif",
"djf",
"gnf",
"kmf",
"mga",
"rwf",
"xpf",
"htg",
"vuv",
"xag",
"xdr",
"xau",
]
+34
View File
@@ -0,0 +1,34 @@
"use client"
import React, { createContext, useContext } from "react"
interface ModalContext {
close: () => void
}
const ModalContext = createContext<ModalContext | null>(null)
interface ModalProviderProps {
children?: React.ReactNode
close: () => void
}
export const ModalProvider = ({ children, close }: ModalProviderProps) => {
return (
<ModalContext.Provider
value={{
close,
}}
>
{children}
</ModalContext.Provider>
)
}
export const useModal = () => {
const context = useContext(ModalContext)
if (context === null) {
throw new Error("useModal must be used within a ModalProvider")
}
return context
}
+470
View File
@@ -0,0 +1,470 @@
"use server"
import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { HttpTypes } from "@medusajs/types"
import { revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import {
getAuthHeaders,
getCacheOptions,
getCacheTag,
getCartId,
removeCartId,
setCartId,
} from "./cookies"
import { getRegion } from "./regions"
/**
* Retrieves a cart by its ID. If no ID is provided, it will use the cart ID from the cookies.
* @param cartId - optional - The ID of the cart to retrieve.
* @returns The cart object if found, or null if not found.
*/
export async function retrieveCart(cartId?: string, fields?: string) {
const id = cartId || (await getCartId())
fields ??= "*items, *region, *items.product, *items.variant, *items.thumbnail, *items.metadata, +items.total, *promotions, +shipping_methods.name"
if (!id) {
return null
}
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("carts")),
}
return await sdk.client
.fetch<HttpTypes.StoreCartResponse>(`/store/carts/${id}`, {
method: "GET",
query: {
fields
},
headers,
next,
cache: "force-cache",
})
.then(({ cart }: { cart: HttpTypes.StoreCart }) => cart)
.catch(() => null)
}
export async function getOrSetCart(countryCode: string) {
const region = await getRegion(countryCode)
if (!region) {
throw new Error(`Region not found for country code: ${countryCode}`)
}
let cart = await retrieveCart(undefined, 'id,region_id')
const headers = {
...(await getAuthHeaders()),
}
if (!cart) {
const cartResp = await sdk.store.cart.create(
{ region_id: region.id },
{},
headers
)
cart = cartResp.cart
await setCartId(cart.id)
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
}
if (cart && cart?.region_id !== region.id) {
await sdk.store.cart.update(cart.id, { region_id: region.id }, {}, headers)
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
}
return cart
}
export async function updateCart(data: HttpTypes.StoreUpdateCart) {
const cartId = await getCartId()
if (!cartId) {
throw new Error("No existing cart found, please create one before updating")
}
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.cart
.update(cartId, data, {}, headers)
.then(async ({ cart }: { cart: HttpTypes.StoreCart }) => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
const fulfillmentCacheTag = await getCacheTag("fulfillment")
revalidateTag(fulfillmentCacheTag)
return cart
})
.catch(medusaError)
}
export async function addToCart({
variantId,
quantity,
countryCode,
}: {
variantId: string
quantity: number
countryCode: string
}) {
if (!variantId) {
throw new Error("Missing variant ID when adding to cart")
}
const cart = await getOrSetCart(countryCode)
if (!cart) {
throw new Error("Error retrieving or creating cart")
}
const headers = {
...(await getAuthHeaders()),
}
await sdk.store.cart
.createLineItem(
cart.id,
{
variant_id: variantId,
quantity,
},
{},
headers
)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
const fulfillmentCacheTag = await getCacheTag("fulfillment")
revalidateTag(fulfillmentCacheTag)
})
.catch(medusaError)
}
export async function updateLineItem({
lineId,
quantity,
}: {
lineId: string
quantity: number
}) {
if (!lineId) {
throw new Error("Missing lineItem ID when updating line item")
}
const cartId = await getCartId()
if (!cartId) {
throw new Error("Missing cart ID when updating line item")
}
const headers = {
...(await getAuthHeaders()),
}
await sdk.store.cart
.updateLineItem(cartId, lineId, { quantity }, {}, headers)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
const fulfillmentCacheTag = await getCacheTag("fulfillment")
revalidateTag(fulfillmentCacheTag)
})
.catch(medusaError)
}
export async function deleteLineItem(lineId: string) {
if (!lineId) {
throw new Error("Missing lineItem ID when deleting line item")
}
const cartId = await getCartId()
if (!cartId) {
throw new Error("Missing cart ID when deleting line item")
}
const headers = {
...(await getAuthHeaders()),
}
await sdk.store.cart
.deleteLineItem(cartId, lineId, headers)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
const fulfillmentCacheTag = await getCacheTag("fulfillment")
revalidateTag(fulfillmentCacheTag)
})
.catch(medusaError)
}
export async function setShippingMethod({
cartId,
shippingMethodId,
}: {
cartId: string
shippingMethodId: string
}) {
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.cart
.addShippingMethod(cartId, { option_id: shippingMethodId }, {}, headers)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
})
.catch(medusaError)
}
export async function initiatePaymentSession(
cart: HttpTypes.StoreCart,
data: HttpTypes.StoreInitializePaymentSession
) {
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.payment
.initiatePaymentSession(cart, data, {}, headers)
.then(async (resp) => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
return resp
})
.catch(medusaError)
}
export async function applyPromotions(codes: string[]) {
const cartId = await getCartId()
if (!cartId) {
throw new Error("No existing cart found")
}
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.cart
.update(cartId, { promo_codes: codes }, {}, headers)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
const fulfillmentCacheTag = await getCacheTag("fulfillment")
revalidateTag(fulfillmentCacheTag)
})
.catch(medusaError)
}
export async function applyGiftCard(code: string) {
// const cartId = getCartId()
// if (!cartId) return "No cartId cookie found"
// try {
// await updateCart(cartId, { gift_cards: [{ code }] }).then(() => {
// revalidateTag("cart")
// })
// } catch (error: any) {
// throw error
// }
}
export async function removeDiscount(code: string) {
// const cartId = getCartId()
// if (!cartId) return "No cartId cookie found"
// try {
// await deleteDiscount(cartId, code)
// revalidateTag("cart")
// } catch (error: any) {
// throw error
// }
}
export async function removeGiftCard(
codeToRemove: string,
giftCards: any[]
// giftCards: GiftCard[]
) {
// const cartId = getCartId()
// if (!cartId) return "No cartId cookie found"
// try {
// await updateCart(cartId, {
// gift_cards: [...giftCards]
// .filter((gc) => gc.code !== codeToRemove)
// .map((gc) => ({ code: gc.code })),
// }).then(() => {
// revalidateTag("cart")
// })
// } catch (error: any) {
// throw error
// }
}
export async function submitPromotionForm(
currentState: unknown,
formData: FormData
) {
const code = formData.get("code") as string
try {
await applyPromotions([code])
} catch (e: any) {
return e.message
}
}
// TODO: Pass a POJO instead of a form entity here
export async function setAddresses(currentState: unknown, formData: FormData) {
try {
if (!formData) {
throw new Error("No form data found when setting addresses")
}
const cartId = getCartId()
if (!cartId) {
throw new Error("No existing cart found when setting addresses")
}
const data = {
shipping_address: {
first_name: formData.get("shipping_address.first_name"),
last_name: formData.get("shipping_address.last_name"),
address_1: formData.get("shipping_address.address_1"),
address_2: "",
company: formData.get("shipping_address.company"),
postal_code: formData.get("shipping_address.postal_code"),
city: formData.get("shipping_address.city"),
country_code: formData.get("shipping_address.country_code"),
province: formData.get("shipping_address.province"),
phone: formData.get("shipping_address.phone"),
},
email: formData.get("email"),
} as any
const sameAsBilling = formData.get("same_as_billing")
if (sameAsBilling === "on") data.billing_address = data.shipping_address
if (sameAsBilling !== "on")
data.billing_address = {
first_name: formData.get("billing_address.first_name"),
last_name: formData.get("billing_address.last_name"),
address_1: formData.get("billing_address.address_1"),
address_2: "",
company: formData.get("billing_address.company"),
postal_code: formData.get("billing_address.postal_code"),
city: formData.get("billing_address.city"),
country_code: formData.get("billing_address.country_code"),
province: formData.get("billing_address.province"),
phone: formData.get("billing_address.phone"),
}
await updateCart(data)
} catch (e: any) {
return e.message
}
redirect(
`/${formData.get("shipping_address.country_code")}/checkout?step=delivery`
)
}
/**
* Places an order for a cart. If no cart ID is provided, it will use the cart ID from the cookies.
* @param cartId - optional - The ID of the cart to place an order for.
* @returns The cart object if the order was successful, or null if not.
*/
export async function placeOrder(cartId?: string) {
const id = cartId || (await getCartId())
if (!id) {
throw new Error("No existing cart found when placing an order")
}
const headers = {
...(await getAuthHeaders()),
}
const cartRes = await sdk.store.cart
.complete(id, {}, headers)
.then(async (cartRes) => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
return cartRes
})
.catch(medusaError)
if (cartRes?.type === "order") {
const countryCode =
cartRes.order.shipping_address?.country_code?.toLowerCase()
const orderCacheTag = await getCacheTag("orders")
revalidateTag(orderCacheTag)
removeCartId()
redirect(`/${countryCode}/order/${cartRes?.order.id}/confirmed`)
}
return cartRes.cart
}
/**
* Updates the countrycode param and revalidates the regions cache
* @param regionId
* @param countryCode
*/
export async function updateRegion(countryCode: string, currentPath: string) {
const cartId = await getCartId()
const region = await getRegion(countryCode)
if (!region) {
throw new Error(`Region not found for country code: ${countryCode}`)
}
if (cartId) {
await updateCart({ region_id: region.id })
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
}
const regionCacheTag = await getCacheTag("regions")
revalidateTag(regionCacheTag)
const productsCacheTag = await getCacheTag("products")
revalidateTag(productsCacheTag)
redirect(`/${countryCode}${currentPath}`)
}
export async function listCartOptions() {
const cartId = await getCartId()
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("shippingOptions")),
}
return await sdk.client.fetch<{
shipping_options: HttpTypes.StoreCartShippingOption[]
}>("/store/shipping-options", {
query: { cart_id: cartId },
next,
headers,
cache: "force-cache",
})
}
+49
View File
@@ -0,0 +1,49 @@
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
import { getCacheOptions } from "./cookies"
export const listCategories = async (query?: Record<string, any>) => {
const next = {
...(await getCacheOptions("categories")),
}
const limit = query?.limit || 100
return sdk.client
.fetch<{ product_categories: HttpTypes.StoreProductCategory[] }>(
"/store/product-categories",
{
query: {
fields:
"*category_children, *products, *parent_category, *parent_category.parent_category",
limit,
...query,
},
next,
cache: "force-cache",
}
)
.then(({ product_categories }) => product_categories)
}
export const getCategoryByHandle = async (categoryHandle: string[]) => {
const handle = `${categoryHandle.join("/")}`
const next = {
...(await getCacheOptions("categories")),
}
return sdk.client
.fetch<HttpTypes.StoreProductCategoryListResponse>(
`/store/product-categories`,
{
query: {
fields: "*category_children, *products",
handle,
},
next,
cache: "force-cache",
}
)
.then(({ product_categories }) => product_categories[0])
}
+59
View File
@@ -0,0 +1,59 @@
"use server"
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
import { getCacheOptions } from "./cookies"
export const retrieveCollection = async (id: string) => {
const next = {
...(await getCacheOptions("collections")),
}
return sdk.client
.fetch<{ collection: HttpTypes.StoreCollection }>(
`/store/collections/${id}`,
{
next,
cache: "force-cache",
}
)
.then(({ collection }) => collection)
}
export const listCollections = async (
queryParams: Record<string, string> = {}
): Promise<{ collections: HttpTypes.StoreCollection[]; count: number }> => {
const next = {
...(await getCacheOptions("collections")),
}
queryParams.limit = queryParams.limit || "100"
queryParams.offset = queryParams.offset || "0"
return sdk.client
.fetch<{ collections: HttpTypes.StoreCollection[]; count: number }>(
"/store/collections",
{
query: queryParams,
next,
cache: "force-cache",
}
)
.then(({ collections }) => ({ collections, count: collections.length }))
}
export const getCollectionByHandle = async (
handle: string
): Promise<HttpTypes.StoreCollection> => {
const next = {
...(await getCacheOptions("collections")),
}
return sdk.client
.fetch<HttpTypes.StoreCollectionListResponse>(`/store/collections`, {
query: { handle, fields: "*products" },
next,
cache: "force-cache",
})
.then(({ collections }) => collections[0])
}
+89
View File
@@ -0,0 +1,89 @@
import "server-only"
import { cookies as nextCookies } from "next/headers"
export const getAuthHeaders = async (): Promise<
{ authorization: string } | {}
> => {
try {
const cookies = await nextCookies()
const token = cookies.get("_medusa_jwt")?.value
if (!token) {
return {}
}
return { authorization: `Bearer ${token}` }
} catch {
return {}
}
}
export const getCacheTag = async (tag: string): Promise<string> => {
try {
const cookies = await nextCookies()
const cacheId = cookies.get("_medusa_cache_id")?.value
if (!cacheId) {
return ""
}
return `${tag}-${cacheId}`
} catch (error) {
return ""
}
}
export const getCacheOptions = async (
tag: string
): Promise<{ tags: string[] } | {}> => {
if (typeof window !== "undefined") {
return {}
}
const cacheTag = await getCacheTag(tag)
if (!cacheTag) {
return {}
}
return { tags: [`${cacheTag}`] }
}
export const setAuthToken = async (token: string) => {
const cookies = await nextCookies()
cookies.set("_medusa_jwt", token, {
maxAge: 60 * 60 * 24 * 7,
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
})
}
export const removeAuthToken = async () => {
const cookies = await nextCookies()
cookies.set("_medusa_jwt", "", {
maxAge: -1,
})
}
export const getCartId = async () => {
const cookies = await nextCookies()
return cookies.get("_medusa_cart_id")?.value
}
export const setCartId = async (cartId: string) => {
const cookies = await nextCookies()
cookies.set("_medusa_cart_id", cartId, {
maxAge: 60 * 60 * 24 * 7,
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
})
}
export const removeCartId = async () => {
const cookies = await nextCookies()
cookies.set("_medusa_cart_id", "", {
maxAge: -1,
})
}
+261
View File
@@ -0,0 +1,261 @@
"use server"
import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { HttpTypes } from "@medusajs/types"
import { revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import {
getAuthHeaders,
getCacheOptions,
getCacheTag,
getCartId,
removeAuthToken,
removeCartId,
setAuthToken,
} from "./cookies"
export const retrieveCustomer =
async (): Promise<HttpTypes.StoreCustomer | null> => {
const authHeaders = await getAuthHeaders()
if (!authHeaders) return null
const headers = {
...authHeaders,
}
const next = {
...(await getCacheOptions("customers")),
}
return await sdk.client
.fetch<{ customer: HttpTypes.StoreCustomer }>(`/store/customers/me`, {
method: "GET",
query: {
fields: "*orders",
},
headers,
next,
cache: "force-cache",
})
.then(({ customer }) => customer)
.catch(() => null)
}
export const updateCustomer = async (body: HttpTypes.StoreUpdateCustomer) => {
const headers = {
...(await getAuthHeaders()),
}
const updateRes = await sdk.store.customer
.update(body, {}, headers)
.then(({ customer }) => customer)
.catch(medusaError)
const cacheTag = await getCacheTag("customers")
revalidateTag(cacheTag)
return updateRes
}
export async function signup(_currentState: unknown, formData: FormData) {
const password = formData.get("password") as string
const customerForm = {
email: formData.get("email") as string,
first_name: formData.get("first_name") as string,
last_name: formData.get("last_name") as string,
phone: formData.get("phone") as string,
}
try {
const token = await sdk.auth.register("customer", "emailpass", {
email: customerForm.email,
password: password,
})
await setAuthToken(token as string)
const headers = {
...(await getAuthHeaders()),
}
const { customer: createdCustomer } = await sdk.store.customer.create(
customerForm,
{},
headers
)
const loginToken = await sdk.auth.login("customer", "emailpass", {
email: customerForm.email,
password,
})
await setAuthToken(loginToken as string)
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
await transferCart()
return createdCustomer
} catch (error: any) {
return error.toString()
}
}
export async function login(_currentState: unknown, formData: FormData) {
const email = formData.get("email") as string
const password = formData.get("password") as string
try {
await sdk.auth
.login("customer", "emailpass", { email, password })
.then(async (token) => {
await setAuthToken(token as string)
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
})
} catch (error: any) {
return error.toString()
}
try {
await transferCart()
} catch (error: any) {
return error.toString()
}
}
export async function signout(countryCode: string) {
await sdk.auth.logout()
await removeAuthToken()
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
await removeCartId()
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
redirect(`/${countryCode}/account`)
}
export async function transferCart() {
const cartId = await getCartId()
if (!cartId) {
return
}
const headers = await getAuthHeaders()
await sdk.store.cart.transferCart(cartId, {}, headers)
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
}
export const addCustomerAddress = async (
currentState: Record<string, unknown>,
formData: FormData
): Promise<any> => {
const isDefaultBilling = (currentState.isDefaultBilling as boolean) || false
const isDefaultShipping = (currentState.isDefaultShipping as boolean) || false
const address = {
first_name: formData.get("first_name") as string,
last_name: formData.get("last_name") as string,
company: formData.get("company") as string,
address_1: formData.get("address_1") as string,
address_2: formData.get("address_2") as string,
city: formData.get("city") as string,
postal_code: formData.get("postal_code") as string,
province: formData.get("province") as string,
country_code: formData.get("country_code") as string,
phone: formData.get("phone") as string,
is_default_billing: isDefaultBilling,
is_default_shipping: isDefaultShipping,
}
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.customer
.createAddress(address, {}, headers)
.then(async ({ customer }) => {
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
return { success: true, error: null }
})
.catch((err) => {
return { success: false, error: err.toString() }
})
}
export const deleteCustomerAddress = async (
addressId: string
): Promise<void> => {
const headers = {
...(await getAuthHeaders()),
}
await sdk.store.customer
.deleteAddress(addressId, headers)
.then(async () => {
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
return { success: true, error: null }
})
.catch((err) => {
return { success: false, error: err.toString() }
})
}
export const updateCustomerAddress = async (
currentState: Record<string, unknown>,
formData: FormData
): Promise<any> => {
const addressId =
(currentState.addressId as string) || (formData.get("addressId") as string)
if (!addressId) {
return { success: false, error: "Address ID is required" }
}
const address = {
first_name: formData.get("first_name") as string,
last_name: formData.get("last_name") as string,
company: formData.get("company") as string,
address_1: formData.get("address_1") as string,
address_2: formData.get("address_2") as string,
city: formData.get("city") as string,
postal_code: formData.get("postal_code") as string,
province: formData.get("province") as string,
country_code: formData.get("country_code") as string,
} as HttpTypes.StoreUpdateCustomerAddress
const phone = formData.get("phone") as string
if (phone) {
address.phone = phone
}
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.customer
.updateAddress(addressId, address, {}, headers)
.then(async () => {
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
return { success: true, error: null }
})
.catch((err) => {
return { success: false, error: err.toString() }
})
}
+68
View File
@@ -0,0 +1,68 @@
"use server"
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
import { getAuthHeaders, getCacheOptions } from "./cookies"
export const listCartShippingMethods = async (cartId: string) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("fulfillment")),
}
return sdk.client
.fetch<HttpTypes.StoreShippingOptionListResponse>(
`/store/shipping-options`,
{
method: "GET",
query: {
cart_id: cartId,
},
headers,
next,
cache: "force-cache",
}
)
.then(({ shipping_options }) => shipping_options)
.catch(() => {
return null
})
}
export const calculatePriceForShippingOption = async (
optionId: string,
cartId: string,
data?: Record<string, unknown>
) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("fulfillment")),
}
const body = { cart_id: cartId, data }
if (data) {
body.data = data
}
return sdk.client
.fetch<{ shipping_option: HttpTypes.StoreCartShippingOption }>(
`/store/shipping-options/${optionId}/calculate`,
{
method: "POST",
body,
headers,
next,
}
)
.then(({ shipping_option }) => shipping_option)
.catch((e) => {
return null
})
}
+9
View File
@@ -0,0 +1,9 @@
"use server"
import { cookies as nextCookies } from "next/headers"
import { redirect } from "next/navigation"
export async function resetOnboardingState(orderId: string) {
const cookies = await nextCookies()
cookies.set("_medusa_onboarding", "false", { maxAge: -1 })
redirect(`http://localhost:7001/a/orders/${orderId}`)
}
+112
View File
@@ -0,0 +1,112 @@
"use server"
import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { getAuthHeaders, getCacheOptions } from "./cookies"
import { HttpTypes } from "@medusajs/types"
export const retrieveOrder = async (id: string) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("orders")),
}
return sdk.client
.fetch<HttpTypes.StoreOrderResponse>(`/store/orders/${id}`, {
method: "GET",
query: {
fields:
"*payment_collections.payments,*items,*items.metadata,*items.variant,*items.product",
},
headers,
next,
cache: "force-cache",
})
.then(({ order }) => order)
.catch((err) => medusaError(err))
}
export const listOrders = async (
limit: number = 10,
offset: number = 0,
filters?: Record<string, any>
) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("orders")),
}
return sdk.client
.fetch<HttpTypes.StoreOrderListResponse>(`/store/orders`, {
method: "GET",
query: {
limit,
offset,
order: "-created_at",
fields: "*items,+items.metadata,*items.variant,*items.product",
...filters,
},
headers,
next,
cache: "force-cache",
})
.then(({ orders }) => orders)
.catch((err) => medusaError(err))
}
export const createTransferRequest = async (
state: {
success: boolean
error: string | null
order: HttpTypes.StoreOrder | null
},
formData: FormData
): Promise<{
success: boolean
error: string | null
order: HttpTypes.StoreOrder | null
}> => {
const id = formData.get("order_id") as string
if (!id) {
return { success: false, error: "Order ID is required", order: null }
}
const headers = await getAuthHeaders()
return await sdk.store.order
.requestTransfer(
id,
{},
{
fields: "id, email",
},
headers
)
.then(({ order }) => ({ success: true, error: null, order }))
.catch((err) => ({ success: false, error: err.message, order: null }))
}
export const acceptTransferRequest = async (id: string, token: string) => {
const headers = await getAuthHeaders()
return await sdk.store.order
.acceptTransfer(id, { token }, {}, headers)
.then(({ order }) => ({ success: true, error: null, order }))
.catch((err) => ({ success: false, error: err.message, order: null }))
}
export const declineTransferRequest = async (id: string, token: string) => {
const headers = await getAuthHeaders()
return await sdk.store.order
.declineTransfer(id, { token }, {}, headers)
.then(({ order }) => ({ success: true, error: null, order }))
.catch((err) => ({ success: false, error: err.message, order: null }))
}
+35
View File
@@ -0,0 +1,35 @@
"use server"
import { sdk } from "@lib/config"
import { getAuthHeaders, getCacheOptions } from "./cookies"
import { HttpTypes } from "@medusajs/types"
export const listCartPaymentMethods = async (regionId: string) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("payment_providers")),
}
return sdk.client
.fetch<HttpTypes.StorePaymentProviderListResponse>(
`/store/payment-providers`,
{
method: "GET",
query: { region_id: regionId },
headers,
next,
cache: "force-cache",
}
)
.then(({ payment_providers }) =>
payment_providers.sort((a, b) => {
return a.id > b.id ? 1 : -1
})
)
.catch(() => {
return null
})
}
+136
View File
@@ -0,0 +1,136 @@
"use server"
import { sdk } from "@lib/config"
import { sortProducts } from "@lib/util/sort-products"
import { HttpTypes } from "@medusajs/types"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
import { getAuthHeaders, getCacheOptions } from "./cookies"
import { getRegion, retrieveRegion } from "./regions"
export const listProducts = async ({
pageParam = 1,
queryParams,
countryCode,
regionId,
}: {
pageParam?: number
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductListParams
countryCode?: string
regionId?: string
}): Promise<{
response: { products: HttpTypes.StoreProduct[]; count: number }
nextPage: number | null
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductListParams
}> => {
if (!countryCode && !regionId) {
throw new Error("Country code or region ID is required")
}
const limit = queryParams?.limit || 12
const _pageParam = Math.max(pageParam, 1)
const offset = _pageParam === 1 ? 0 : (_pageParam - 1) * limit
let region: HttpTypes.StoreRegion | undefined | null
if (countryCode) {
region = await getRegion(countryCode)
} else {
region = await retrieveRegion(regionId!)
}
if (!region) {
return {
response: { products: [], count: 0 },
nextPage: null,
}
}
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("products")),
}
return sdk.client
.fetch<{ products: HttpTypes.StoreProduct[]; count: number }>(
`/store/products`,
{
method: "GET",
query: {
limit,
offset,
region_id: region?.id,
fields:
"*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags",
...queryParams,
},
headers,
next,
cache: "force-cache",
}
)
.then(({ products, count }) => {
const nextPage = count > offset + limit ? pageParam + 1 : null
return {
response: {
products,
count,
},
nextPage: nextPage,
queryParams,
}
})
}
/**
* This will fetch 100 products to the Next.js cache and sort them based on the sortBy parameter.
* It will then return the paginated products based on the page and limit parameters.
*/
export const listProductsWithSort = async ({
page = 0,
queryParams,
sortBy = "created_at",
countryCode,
}: {
page?: number
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
sortBy?: SortOptions
countryCode: string
}): Promise<{
response: { products: HttpTypes.StoreProduct[]; count: number }
nextPage: number | null
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
}> => {
const limit = queryParams?.limit || 12
const {
response: { products, count },
} = await listProducts({
pageParam: 0,
queryParams: {
...queryParams,
limit: 100,
},
countryCode,
})
const sortedProducts = sortProducts(products, sortBy)
const pageParam = (page - 1) * limit
const nextPage = count > pageParam + limit ? pageParam + limit : null
const paginatedProducts = sortedProducts.slice(pageParam, pageParam + limit)
return {
response: {
products: paginatedProducts,
count,
},
nextPage,
queryParams,
}
}
+66
View File
@@ -0,0 +1,66 @@
"use server"
import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { HttpTypes } from "@medusajs/types"
import { getCacheOptions } from "./cookies"
export const listRegions = async () => {
const next = {
...(await getCacheOptions("regions")),
}
return sdk.client
.fetch<{ regions: HttpTypes.StoreRegion[] }>(`/store/regions`, {
method: "GET",
next,
cache: "force-cache",
})
.then(({ regions }) => regions)
.catch(medusaError)
}
export const retrieveRegion = async (id: string) => {
const next = {
...(await getCacheOptions(["regions", id].join("-"))),
}
return sdk.client
.fetch<{ region: HttpTypes.StoreRegion }>(`/store/regions/${id}`, {
method: "GET",
next,
cache: "force-cache",
})
.then(({ region }) => region)
.catch(medusaError)
}
const regionMap = new Map<string, HttpTypes.StoreRegion>()
export const getRegion = async (countryCode: string) => {
try {
if (regionMap.has(countryCode)) {
return regionMap.get(countryCode)
}
const regions = await listRegions()
if (!regions) {
return null
}
regions.forEach((region) => {
region.countries?.forEach((c) => {
regionMap.set(c?.iso_2 ?? "", region)
})
})
const region = countryCode
? regionMap.get(countryCode)
: regionMap.get("us")
return region
} catch (e: any) {
return null
}
}
+29
View File
@@ -0,0 +1,29 @@
import { RefObject, useEffect, useState } from "react"
export const useIntersection = (
element: RefObject<HTMLDivElement | null>,
rootMargin: string
) => {
const [isVisible, setState] = useState(false)
useEffect(() => {
if (!element.current) {
return
}
const el = element.current
const observer = new IntersectionObserver(
([entry]) => {
setState(entry.isIntersecting)
},
{ rootMargin }
)
observer.observe(el)
return () => observer.unobserve(el)
}, [element, rootMargin])
return isVisible
}
+46
View File
@@ -0,0 +1,46 @@
import { useState } from "react"
export type StateType = [boolean, () => void, () => void, () => void] & {
state: boolean
open: () => void
close: () => void
toggle: () => void
}
/**
*
* @param initialState - boolean
* @returns An array like object with `state`, `open`, `close`, and `toggle` properties
* to allow both object and array destructuring
*
* ```
* const [showModal, openModal, closeModal, toggleModal] = useToggleState()
* // or
* const { state, open, close, toggle } = useToggleState()
* ```
*/
const useToggleState = (initialState = false) => {
const [state, setState] = useState<boolean>(initialState)
const close = () => {
setState(false)
}
const open = () => {
setState(true)
}
const toggle = () => {
setState((state) => !state)
}
const hookData = [state, open, close, toggle] as StateType
hookData.state = state
hookData.open = open
hookData.close = close
hookData.toggle = toggle
return hookData
}
export default useToggleState
+28
View File
@@ -0,0 +1,28 @@
import { isEqual, pick } from "lodash"
export default function compareAddresses(address1: any, address2: any) {
return isEqual(
pick(address1, [
"first_name",
"last_name",
"address_1",
"company",
"postal_code",
"city",
"country_code",
"province",
"phone",
]),
pick(address2, [
"first_name",
"last_name",
"address_1",
"company",
"postal_code",
"city",
"country_code",
"province",
"phone",
])
)
}
+3
View File
@@ -0,0 +1,3 @@
export const getBaseURL = () => {
return process.env.NEXT_PUBLIC_BASE_URL || "https://localhost:8000"
}
+6
View File
@@ -0,0 +1,6 @@
export const getPercentageDiff = (original: number, calculated: number) => {
const diff = original - calculated
const decrease = (diff / original) * 100
return decrease.toFixed()
}
+79
View File
@@ -0,0 +1,79 @@
import { HttpTypes } from "@medusajs/types"
import { getPercentageDiff } from "./get-precentage-diff"
import { convertToLocale } from "./money"
export const getPricesForVariant = (variant: any) => {
if (!variant?.calculated_price?.calculated_amount) {
return null
}
return {
calculated_price_number: variant.calculated_price.calculated_amount,
calculated_price: convertToLocale({
amount: variant.calculated_price.calculated_amount,
currency_code: variant.calculated_price.currency_code,
}),
original_price_number: variant.calculated_price.original_amount,
original_price: convertToLocale({
amount: variant.calculated_price.original_amount,
currency_code: variant.calculated_price.currency_code,
}),
currency_code: variant.calculated_price.currency_code,
price_type: variant.calculated_price.calculated_price.price_list_type,
percentage_diff: getPercentageDiff(
variant.calculated_price.original_amount,
variant.calculated_price.calculated_amount
),
}
}
export function getProductPrice({
product,
variantId,
}: {
product: HttpTypes.StoreProduct
variantId?: string
}) {
if (!product || !product.id) {
throw new Error("No product provided")
}
const cheapestPrice = () => {
if (!product || !product.variants?.length) {
return null
}
const cheapestVariant: any = product.variants
.filter((v: any) => !!v.calculated_price)
.sort((a: any, b: any) => {
return (
a.calculated_price.calculated_amount -
b.calculated_price.calculated_amount
)
})[0]
return getPricesForVariant(cheapestVariant)
}
const variantPrice = () => {
if (!product || !variantId) {
return null
}
const variant: any = product.variants?.find(
(v) => v.id === variantId || v.sku === variantId
)
if (!variant) {
return null
}
return getPricesForVariant(variant)
}
return {
product,
cheapestPrice: cheapestPrice(),
variantPrice: variantPrice(),
}
}
+11
View File
@@ -0,0 +1,11 @@
export const isObject = (input: any) => input instanceof Object
export const isArray = (input: any) => Array.isArray(input)
export const isEmpty = (input: any) => {
return (
input === null ||
input === undefined ||
(isObject(input) && Object.keys(input).length === 0) ||
(isArray(input) && (input as any[]).length === 0) ||
(typeof input === "string" && input.trim().length === 0)
)
}
+22
View File
@@ -0,0 +1,22 @@
export default function medusaError(error: any): never {
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
const u = new URL(error.config.url, error.config.baseURL)
console.error("Resource:", u.toString())
console.error("Response data:", error.response.data)
console.error("Status code:", error.response.status)
console.error("Headers:", error.response.headers)
// Extracting the error message from the response data
const message = error.response.data.message || error.response.data
throw new Error(message.charAt(0).toUpperCase() + message.slice(1) + ".")
} else if (error.request) {
// The request was made but no response was received
throw new Error("No response received: " + error.request)
} else {
// Something happened in setting up the request that triggered an Error
throw new Error("Error setting up the request: " + error.message)
}
}
+26
View File
@@ -0,0 +1,26 @@
import { isEmpty } from "./isEmpty"
type ConvertToLocaleParams = {
amount: number
currency_code: string
minimumFractionDigits?: number
maximumFractionDigits?: number
locale?: string
}
export const convertToLocale = ({
amount,
currency_code,
minimumFractionDigits,
maximumFractionDigits,
locale = "en-US",
}: ConvertToLocaleParams) => {
return currency_code && !isEmpty(currency_code)
? new Intl.NumberFormat(locale, {
style: "currency",
currency: currency_code,
minimumFractionDigits,
maximumFractionDigits,
}).format(amount)
: amount.toString()
}
+5
View File
@@ -0,0 +1,5 @@
import { HttpTypes } from "@medusajs/types";
export const isSimpleProduct = (product: HttpTypes.StoreProduct): boolean => {
return product.options?.length === 1 && product.options[0].values?.length === 1;
}
+5
View File
@@ -0,0 +1,5 @@
const repeat = (times: number) => {
return Array.from(Array(times).keys())
}
export default repeat
+50
View File
@@ -0,0 +1,50 @@
import { HttpTypes } from "@medusajs/types"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
interface MinPricedProduct extends HttpTypes.StoreProduct {
_minPrice?: number
}
/**
* Helper function to sort products by price until the store API supports sorting by price
* @param products
* @param sortBy
* @returns products sorted by price
*/
export function sortProducts(
products: HttpTypes.StoreProduct[],
sortBy: SortOptions
): HttpTypes.StoreProduct[] {
let sortedProducts = products as MinPricedProduct[]
if (["price_asc", "price_desc"].includes(sortBy)) {
// Precompute the minimum price for each product
sortedProducts.forEach((product) => {
if (product.variants && product.variants.length > 0) {
product._minPrice = Math.min(
...product.variants.map(
(variant) => variant?.calculated_price?.calculated_amount || 0
)
)
} else {
product._minPrice = Infinity
}
})
// Sort products based on the precomputed minimum prices
sortedProducts.sort((a, b) => {
const diff = a._minPrice! - b._minPrice!
return sortBy === "price_asc" ? diff : -diff
})
}
if (sortBy === "created_at") {
sortedProducts.sort((a, b) => {
return (
new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()
)
})
}
return sortedProducts
}
+165
View File
@@ -0,0 +1,165 @@
import { HttpTypes } from "@medusajs/types"
import { NextRequest, NextResponse } from "next/server"
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL
const PUBLISHABLE_API_KEY = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
const DEFAULT_REGION = process.env.NEXT_PUBLIC_DEFAULT_REGION || "us"
const regionMapCache = {
regionMap: new Map<string, HttpTypes.StoreRegion>(),
regionMapUpdated: Date.now(),
}
async function getRegionMap(cacheId: string) {
const { regionMap, regionMapUpdated } = regionMapCache
if (!BACKEND_URL) {
throw new Error(
"Middleware.ts: Error fetching regions. Did you set up regions in your Medusa Admin and define a MEDUSA_BACKEND_URL environment variable? Note that the variable is no longer named NEXT_PUBLIC_MEDUSA_BACKEND_URL."
)
}
if (
!regionMap.keys().next().value ||
regionMapUpdated < Date.now() - 3600 * 1000
) {
// Fetch regions from Medusa. We can't use the JS client here because middleware is running on Edge and the client needs a Node environment.
const { regions } = await fetch(`${BACKEND_URL}/store/regions`, {
headers: {
"x-publishable-api-key": PUBLISHABLE_API_KEY!,
},
next: {
revalidate: 3600,
tags: [`regions-${cacheId}`],
},
cache: "force-cache",
}).then(async (response) => {
const json = await response.json()
if (!response.ok) {
throw new Error(json.message)
}
return json
})
if (!regions?.length) {
throw new Error(
"No regions found. Please set up regions in your Medusa Admin."
)
}
// Create a map of country codes to regions.
regions.forEach((region: HttpTypes.StoreRegion) => {
region.countries?.forEach((c) => {
regionMapCache.regionMap.set(c.iso_2 ?? "", region)
})
})
regionMapCache.regionMapUpdated = Date.now()
}
return regionMapCache.regionMap
}
/**
* Fetches regions from Medusa and sets the region cookie.
* @param request
* @param response
*/
async function getCountryCode(
request: NextRequest,
regionMap: Map<string, HttpTypes.StoreRegion | number>
) {
try {
let countryCode
const vercelCountryCode = request.headers
.get("x-vercel-ip-country")
?.toLowerCase()
const urlCountryCode = request.nextUrl.pathname.split("/")[1]?.toLowerCase()
if (urlCountryCode && regionMap.has(urlCountryCode)) {
countryCode = urlCountryCode
} else if (vercelCountryCode && regionMap.has(vercelCountryCode)) {
countryCode = vercelCountryCode
} else if (regionMap.has(DEFAULT_REGION)) {
countryCode = DEFAULT_REGION
} else if (regionMap.keys().next().value) {
countryCode = regionMap.keys().next().value
}
return countryCode
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.error(
"Middleware.ts: Error getting the country code. Did you set up regions in your Medusa Admin and define a MEDUSA_BACKEND_URL environment variable? Note that the variable is no longer named NEXT_PUBLIC_MEDUSA_BACKEND_URL."
)
}
}
}
/**
* Middleware to handle region selection and onboarding status.
*/
export async function middleware(request: NextRequest) {
let redirectUrl = request.nextUrl.href
let response = NextResponse.redirect(redirectUrl, 307)
let cacheIdCookie = request.cookies.get("_medusa_cache_id")
let cacheId = cacheIdCookie?.value || crypto.randomUUID()
const regionMap = await getRegionMap(cacheId)
const countryCode = regionMap && (await getCountryCode(request, regionMap))
const urlHasCountryCode =
countryCode && request.nextUrl.pathname.split("/")[1].includes(countryCode)
// if one of the country codes is in the url and the cache id is set, return next
if (urlHasCountryCode && cacheIdCookie) {
return NextResponse.next()
}
// if one of the country codes is in the url and the cache id is not set, set the cache id and redirect
if (urlHasCountryCode && !cacheIdCookie) {
response.cookies.set("_medusa_cache_id", cacheId, {
maxAge: 60 * 60 * 24,
})
return response
}
// check if the url is a static asset
if (request.nextUrl.pathname.includes(".")) {
return NextResponse.next()
}
const redirectPath =
request.nextUrl.pathname === "/" ? "" : request.nextUrl.pathname
const queryString = request.nextUrl.search ? request.nextUrl.search : ""
// If no country code is set, we redirect to the relevant region.
if (!urlHasCountryCode && countryCode) {
redirectUrl = `${request.nextUrl.origin}/${countryCode}${redirectPath}${queryString}`
response = NextResponse.redirect(`${redirectUrl}`, 307)
} else if (!urlHasCountryCode && !countryCode) {
// Handle case where no valid country code exists (empty regions)
return new NextResponse(
"No valid regions configured. Please set up regions with countries in your Medusa Admin.",
{ status: 500 }
)
}
return response
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|images|assets|png|svg|jpg|jpeg|gif|webp).*)",
],
}
@@ -0,0 +1,139 @@
import { Disclosure } from "@headlessui/react"
import { Badge, Button, clx } from "@medusajs/ui"
import { useEffect } from "react"
import useToggleState from "@lib/hooks/use-toggle-state"
import { useFormStatus } from "react-dom"
type AccountInfoProps = {
label: string
currentInfo: string | React.ReactNode
isSuccess?: boolean
isError?: boolean
errorMessage?: string
clearState: () => void
children?: React.ReactNode
'data-testid'?: string
}
const AccountInfo = ({
label,
currentInfo,
isSuccess,
isError,
clearState,
errorMessage = "An error occurred, please try again",
children,
'data-testid': dataTestid
}: AccountInfoProps) => {
const { state, close, toggle } = useToggleState()
const { pending } = useFormStatus()
const handleToggle = () => {
clearState()
setTimeout(() => toggle(), 100)
}
useEffect(() => {
if (isSuccess) {
close()
}
}, [isSuccess, close])
return (
<div className="text-small-regular" data-testid={dataTestid}>
<div className="flex items-end justify-between">
<div className="flex flex-col">
<span className="uppercase text-ui-fg-base">{label}</span>
<div className="flex items-center flex-1 basis-0 justify-end gap-x-4">
{typeof currentInfo === "string" ? (
<span className="font-semibold" data-testid="current-info">{currentInfo}</span>
) : (
currentInfo
)}
</div>
</div>
<div>
<Button
variant="secondary"
className="w-[100px] min-h-[25px] py-1"
onClick={handleToggle}
type={state ? "reset" : "button"}
data-testid="edit-button"
data-active={state}
>
{state ? "Cancel" : "Edit"}
</Button>
</div>
</div>
{/* Success state */}
<Disclosure>
<Disclosure.Panel
static
className={clx(
"transition-[max-height,opacity] duration-300 ease-in-out overflow-hidden",
{
"max-h-[1000px] opacity-100": isSuccess,
"max-h-0 opacity-0": !isSuccess,
}
)}
data-testid="success-message"
>
<Badge className="p-2 my-4" color="green">
<span>{label} updated succesfully</span>
</Badge>
</Disclosure.Panel>
</Disclosure>
{/* Error state */}
<Disclosure>
<Disclosure.Panel
static
className={clx(
"transition-[max-height,opacity] duration-300 ease-in-out overflow-hidden",
{
"max-h-[1000px] opacity-100": isError,
"max-h-0 opacity-0": !isError,
}
)}
data-testid="error-message"
>
<Badge className="p-2 my-4" color="red">
<span>{errorMessage}</span>
</Badge>
</Disclosure.Panel>
</Disclosure>
<Disclosure>
<Disclosure.Panel
static
className={clx(
"transition-[max-height,opacity] duration-300 ease-in-out overflow-visible",
{
"max-h-[1000px] opacity-100": state,
"max-h-0 opacity-0": !state,
}
)}
>
<div className="flex flex-col gap-y-2 py-4">
<div>{children}</div>
<div className="flex items-center justify-end mt-2">
<Button
isLoading={pending}
className="w-full small:max-w-[140px]"
type="submit"
data-testid="save-button"
>
Save changes
</Button>
</div>
</div>
</Disclosure.Panel>
</Disclosure>
</div>
)
}
export default AccountInfo
@@ -0,0 +1,199 @@
"use client"
import { clx } from "@medusajs/ui"
import { ArrowRightOnRectangle } from "@medusajs/icons"
import { useParams, usePathname } from "next/navigation"
import ChevronDown from "@modules/common/icons/chevron-down"
import User from "@modules/common/icons/user"
import MapPin from "@modules/common/icons/map-pin"
import Package from "@modules/common/icons/package"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { HttpTypes } from "@medusajs/types"
import { signout } from "@lib/data/customer"
const AccountNav = ({
customer,
}: {
customer: HttpTypes.StoreCustomer | null
}) => {
const route = usePathname()
const { countryCode } = useParams() as { countryCode: string }
const handleLogout = async () => {
await signout(countryCode)
}
return (
<div>
<div className="small:hidden" data-testid="mobile-account-nav">
{route !== `/${countryCode}/account` ? (
<LocalizedClientLink
href="/account"
className="flex items-center gap-x-2 text-small-regular py-2"
data-testid="account-main-link"
>
<>
<ChevronDown className="transform rotate-90" />
<span>Account</span>
</>
</LocalizedClientLink>
) : (
<>
<div className="text-xl-semi mb-4 px-8">
Hello {customer?.first_name}
</div>
<div className="text-base-regular">
<ul>
<li>
<LocalizedClientLink
href="/account/profile"
className="flex items-center justify-between py-4 border-b border-gray-200 px-8"
data-testid="profile-link"
>
<>
<div className="flex items-center gap-x-2">
<User size={20} />
<span>Profile</span>
</div>
<ChevronDown className="transform -rotate-90" />
</>
</LocalizedClientLink>
</li>
<li>
<LocalizedClientLink
href="/account/addresses"
className="flex items-center justify-between py-4 border-b border-gray-200 px-8"
data-testid="addresses-link"
>
<>
<div className="flex items-center gap-x-2">
<MapPin size={20} />
<span>Addresses</span>
</div>
<ChevronDown className="transform -rotate-90" />
</>
</LocalizedClientLink>
</li>
<li>
<LocalizedClientLink
href="/account/orders"
className="flex items-center justify-between py-4 border-b border-gray-200 px-8"
data-testid="orders-link"
>
<div className="flex items-center gap-x-2">
<Package size={20} />
<span>Orders</span>
</div>
<ChevronDown className="transform -rotate-90" />
</LocalizedClientLink>
</li>
<li>
<button
type="button"
className="flex items-center justify-between py-4 border-b border-gray-200 px-8 w-full"
onClick={handleLogout}
data-testid="logout-button"
>
<div className="flex items-center gap-x-2">
<ArrowRightOnRectangle />
<span>Log out</span>
</div>
<ChevronDown className="transform -rotate-90" />
</button>
</li>
</ul>
</div>
</>
)}
</div>
<div className="hidden small:block" data-testid="account-nav">
<div>
<div className="pb-4">
<h3 className="text-base-semi">Account</h3>
</div>
<div className="text-base-regular">
<ul className="flex mb-0 justify-start items-start flex-col gap-y-4">
<li>
<AccountNavLink
href="/account"
route={route!}
data-testid="overview-link"
>
Overview
</AccountNavLink>
</li>
<li>
<AccountNavLink
href="/account/profile"
route={route!}
data-testid="profile-link"
>
Profile
</AccountNavLink>
</li>
<li>
<AccountNavLink
href="/account/addresses"
route={route!}
data-testid="addresses-link"
>
Addresses
</AccountNavLink>
</li>
<li>
<AccountNavLink
href="/account/orders"
route={route!}
data-testid="orders-link"
>
Orders
</AccountNavLink>
</li>
<li className="text-grey-700">
<button
type="button"
onClick={handleLogout}
data-testid="logout-button"
>
Log out
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
)
}
type AccountNavLinkProps = {
href: string
route: string
children: React.ReactNode
"data-testid"?: string
}
const AccountNavLink = ({
href,
route,
children,
"data-testid": dataTestId,
}: AccountNavLinkProps) => {
const { countryCode }: { countryCode: string } = useParams()
const active = route.split(countryCode)[1] === href
return (
<LocalizedClientLink
href={href}
className={clx("text-ui-fg-subtle hover:text-ui-fg-base", {
"text-ui-fg-base font-semibold": active,
})}
data-testid={dataTestId}
>
{children}
</LocalizedClientLink>
)
}
export default AccountNav
@@ -0,0 +1,28 @@
import React from "react"
import AddAddress from "../address-card/add-address"
import EditAddress from "../address-card/edit-address-modal"
import { HttpTypes } from "@medusajs/types"
type AddressBookProps = {
customer: HttpTypes.StoreCustomer
region: HttpTypes.StoreRegion
}
const AddressBook: React.FC<AddressBookProps> = ({ customer, region }) => {
const { addresses } = customer
return (
<div className="w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1 mt-4">
<AddAddress region={region} addresses={addresses} />
{addresses.map((address) => {
return (
<EditAddress region={region} address={address} key={address.id} />
)
})}
</div>
</div>
)
}
export default AddressBook
@@ -0,0 +1,167 @@
"use client"
import { Plus } from "@medusajs/icons"
import { Button, Heading } from "@medusajs/ui"
import { useEffect, useState, useActionState } from "react"
import useToggleState from "@lib/hooks/use-toggle-state"
import CountrySelect from "@modules/checkout/components/country-select"
import Input from "@modules/common/components/input"
import Modal from "@modules/common/components/modal"
import { SubmitButton } from "@modules/checkout/components/submit-button"
import { HttpTypes } from "@medusajs/types"
import { addCustomerAddress } from "@lib/data/customer"
const AddAddress = ({
region,
addresses,
}: {
region: HttpTypes.StoreRegion
addresses: HttpTypes.StoreCustomerAddress[]
}) => {
const [successState, setSuccessState] = useState(false)
const { state, open, close: closeModal } = useToggleState(false)
const [formState, formAction] = useActionState(addCustomerAddress, {
isDefaultShipping: addresses.length === 0,
success: false,
error: null,
})
const close = () => {
setSuccessState(false)
closeModal()
}
useEffect(() => {
if (successState) {
close()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [successState])
useEffect(() => {
if (formState.success) {
setSuccessState(true)
}
}, [formState])
return (
<>
<button
className="border border-ui-border-base rounded-rounded p-5 min-h-[220px] h-full w-full flex flex-col justify-between"
onClick={open}
data-testid="add-address-button"
>
<span className="text-base-semi">New address</span>
<Plus />
</button>
<Modal isOpen={state} close={close} data-testid="add-address-modal">
<Modal.Title>
<Heading className="mb-2">Add address</Heading>
</Modal.Title>
<form action={formAction}>
<Modal.Body>
<div className="flex flex-col gap-y-2">
<div className="grid grid-cols-2 gap-x-2">
<Input
label="First name"
name="first_name"
required
autoComplete="given-name"
data-testid="first-name-input"
/>
<Input
label="Last name"
name="last_name"
required
autoComplete="family-name"
data-testid="last-name-input"
/>
</div>
<Input
label="Company"
name="company"
autoComplete="organization"
data-testid="company-input"
/>
<Input
label="Address"
name="address_1"
required
autoComplete="address-line1"
data-testid="address-1-input"
/>
<Input
label="Apartment, suite, etc."
name="address_2"
autoComplete="address-line2"
data-testid="address-2-input"
/>
<div className="grid grid-cols-[144px_1fr] gap-x-2">
<Input
label="Postal code"
name="postal_code"
required
autoComplete="postal-code"
data-testid="postal-code-input"
/>
<Input
label="City"
name="city"
required
autoComplete="locality"
data-testid="city-input"
/>
</div>
<Input
label="Province / State"
name="province"
autoComplete="address-level1"
data-testid="state-input"
/>
<CountrySelect
region={region}
name="country_code"
required
autoComplete="country"
data-testid="country-select"
/>
<Input
label="Phone"
name="phone"
autoComplete="phone"
data-testid="phone-input"
/>
</div>
{formState.error && (
<div
className="text-rose-500 text-small-regular py-2"
data-testid="address-error"
>
{formState.error}
</div>
)}
</Modal.Body>
<Modal.Footer>
<div className="flex gap-3 mt-6">
<Button
type="reset"
variant="secondary"
onClick={close}
className="h-10"
data-testid="cancel-button"
>
Cancel
</Button>
<SubmitButton data-testid="save-button">Save</SubmitButton>
</div>
</Modal.Footer>
</form>
</Modal>
</>
)
}
export default AddAddress
@@ -0,0 +1,239 @@
"use client"
import React, { useEffect, useState, useActionState } from "react"
import { PencilSquare as Edit, Trash } from "@medusajs/icons"
import { Button, Heading, Text, clx } from "@medusajs/ui"
import useToggleState from "@lib/hooks/use-toggle-state"
import CountrySelect from "@modules/checkout/components/country-select"
import Input from "@modules/common/components/input"
import Modal from "@modules/common/components/modal"
import Spinner from "@modules/common/icons/spinner"
import { SubmitButton } from "@modules/checkout/components/submit-button"
import { HttpTypes } from "@medusajs/types"
import {
deleteCustomerAddress,
updateCustomerAddress,
} from "@lib/data/customer"
type EditAddressProps = {
region: HttpTypes.StoreRegion
address: HttpTypes.StoreCustomerAddress
isActive?: boolean
}
const EditAddress: React.FC<EditAddressProps> = ({
region,
address,
isActive = false,
}) => {
const [removing, setRemoving] = useState(false)
const [successState, setSuccessState] = useState(false)
const { state, open, close: closeModal } = useToggleState(false)
const [formState, formAction] = useActionState(updateCustomerAddress, {
success: false,
error: null,
addressId: address.id,
})
const close = () => {
setSuccessState(false)
closeModal()
}
useEffect(() => {
if (successState) {
close()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [successState])
useEffect(() => {
if (formState.success) {
setSuccessState(true)
}
}, [formState])
const removeAddress = async () => {
setRemoving(true)
await deleteCustomerAddress(address.id)
setRemoving(false)
}
return (
<>
<div
className={clx(
"border rounded-rounded p-5 min-h-[220px] h-full w-full flex flex-col justify-between transition-colors",
{
"border-gray-900": isActive,
}
)}
data-testid="address-container"
>
<div className="flex flex-col">
<Heading
className="text-left text-base-semi"
data-testid="address-name"
>
{address.first_name} {address.last_name}
</Heading>
{address.company && (
<Text
className="txt-compact-small text-ui-fg-base"
data-testid="address-company"
>
{address.company}
</Text>
)}
<Text className="flex flex-col text-left text-base-regular mt-2">
<span data-testid="address-address">
{address.address_1}
{address.address_2 && <span>, {address.address_2}</span>}
</span>
<span data-testid="address-postal-city">
{address.postal_code}, {address.city}
</span>
<span data-testid="address-province-country">
{address.province && `${address.province}, `}
{address.country_code?.toUpperCase()}
</span>
</Text>
</div>
<div className="flex items-center gap-x-4">
<button
className="text-small-regular text-ui-fg-base flex items-center gap-x-2"
onClick={open}
data-testid="address-edit-button"
>
<Edit />
Edit
</button>
<button
className="text-small-regular text-ui-fg-base flex items-center gap-x-2"
onClick={removeAddress}
data-testid="address-delete-button"
>
{removing ? <Spinner /> : <Trash />}
Remove
</button>
</div>
</div>
<Modal isOpen={state} close={close} data-testid="edit-address-modal">
<Modal.Title>
<Heading className="mb-2">Edit address</Heading>
</Modal.Title>
<form action={formAction}>
<input type="hidden" name="addressId" value={address.id} />
<Modal.Body>
<div className="grid grid-cols-1 gap-y-2">
<div className="grid grid-cols-2 gap-x-2">
<Input
label="First name"
name="first_name"
required
autoComplete="given-name"
defaultValue={address.first_name || undefined}
data-testid="first-name-input"
/>
<Input
label="Last name"
name="last_name"
required
autoComplete="family-name"
defaultValue={address.last_name || undefined}
data-testid="last-name-input"
/>
</div>
<Input
label="Company"
name="company"
autoComplete="organization"
defaultValue={address.company || undefined}
data-testid="company-input"
/>
<Input
label="Address"
name="address_1"
required
autoComplete="address-line1"
defaultValue={address.address_1 || undefined}
data-testid="address-1-input"
/>
<Input
label="Apartment, suite, etc."
name="address_2"
autoComplete="address-line2"
defaultValue={address.address_2 || undefined}
data-testid="address-2-input"
/>
<div className="grid grid-cols-[144px_1fr] gap-x-2">
<Input
label="Postal code"
name="postal_code"
required
autoComplete="postal-code"
defaultValue={address.postal_code || undefined}
data-testid="postal-code-input"
/>
<Input
label="City"
name="city"
required
autoComplete="locality"
defaultValue={address.city || undefined}
data-testid="city-input"
/>
</div>
<Input
label="Province / State"
name="province"
autoComplete="address-level1"
defaultValue={address.province || undefined}
data-testid="state-input"
/>
<CountrySelect
name="country_code"
region={region}
required
autoComplete="country"
defaultValue={address.country_code || undefined}
data-testid="country-select"
/>
<Input
label="Phone"
name="phone"
autoComplete="phone"
defaultValue={address.phone || undefined}
data-testid="phone-input"
/>
</div>
{formState.error && (
<div className="text-rose-500 text-small-regular py-2">
{formState.error}
</div>
)}
</Modal.Body>
<Modal.Footer>
<div className="flex gap-3 mt-6">
<Button
type="reset"
variant="secondary"
onClick={close}
className="h-10"
data-testid="cancel-button"
>
Cancel
</Button>
<SubmitButton data-testid="save-button">Save</SubmitButton>
</div>
</Modal.Footer>
</form>
</Modal>
</>
)
}
export default EditAddress
@@ -0,0 +1,64 @@
import { login } from "@lib/data/customer"
import { LOGIN_VIEW } from "@modules/account/templates/login-template"
import ErrorMessage from "@modules/checkout/components/error-message"
import { SubmitButton } from "@modules/checkout/components/submit-button"
import Input from "@modules/common/components/input"
import { useActionState } from "react"
type Props = {
setCurrentView: (view: LOGIN_VIEW) => void
}
const Login = ({ setCurrentView }: Props) => {
const [message, formAction] = useActionState(login, null)
return (
<div
className="max-w-sm w-full flex flex-col items-center"
data-testid="login-page"
>
<h1 className="text-large-semi uppercase mb-6">Welcome back</h1>
<p className="text-center text-base-regular text-ui-fg-base mb-8">
Sign in to access an enhanced shopping experience.
</p>
<form className="w-full" action={formAction}>
<div className="flex flex-col w-full gap-y-2">
<Input
label="Email"
name="email"
type="email"
title="Enter a valid email address."
autoComplete="email"
required
data-testid="email-input"
/>
<Input
label="Password"
name="password"
type="password"
autoComplete="current-password"
required
data-testid="password-input"
/>
</div>
<ErrorMessage error={message} data-testid="login-error-message" />
<SubmitButton data-testid="sign-in-button" className="w-full mt-6">
Sign in
</SubmitButton>
</form>
<span className="text-center text-ui-fg-base text-small-regular mt-6">
Not a member?{" "}
<button
onClick={() => setCurrentView(LOGIN_VIEW.REGISTER)}
className="underline"
data-testid="register-button"
>
Join us
</button>
.
</span>
</div>
)
}
export default Login
@@ -0,0 +1,87 @@
import { Button } from "@medusajs/ui"
import { useMemo } from "react"
import Thumbnail from "@modules/products/components/thumbnail"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { convertToLocale } from "@lib/util/money"
import { HttpTypes } from "@medusajs/types"
type OrderCardProps = {
order: HttpTypes.StoreOrder
}
const OrderCard = ({ order }: OrderCardProps) => {
const numberOfLines = useMemo(() => {
return (
order.items?.reduce((acc, item) => {
return acc + item.quantity
}, 0) ?? 0
)
}, [order])
const numberOfProducts = useMemo(() => {
return order.items?.length ?? 0
}, [order])
return (
<div className="bg-white flex flex-col" data-testid="order-card">
<div className="uppercase text-large-semi mb-1">
#<span data-testid="order-display-id">{order.display_id}</span>
</div>
<div className="flex items-center divide-x divide-gray-200 text-small-regular text-ui-fg-base">
<span className="pr-2" data-testid="order-created-at">
{new Date(order.created_at).toDateString()}
</span>
<span className="px-2" data-testid="order-amount">
{convertToLocale({
amount: order.total,
currency_code: order.currency_code,
})}
</span>
<span className="pl-2">{`${numberOfLines} ${
numberOfLines > 1 ? "items" : "item"
}`}</span>
</div>
<div className="grid grid-cols-2 small:grid-cols-4 gap-4 my-4">
{order.items?.slice(0, 3).map((i) => {
return (
<div
key={i.id}
className="flex flex-col gap-y-2"
data-testid="order-item"
>
<Thumbnail thumbnail={i.thumbnail} images={[]} size="full" />
<div className="flex items-center text-small-regular text-ui-fg-base">
<span
className="text-ui-fg-base font-semibold"
data-testid="item-title"
>
{i.title}
</span>
<span className="ml-2">x</span>
<span data-testid="item-quantity">{i.quantity}</span>
</div>
</div>
)
})}
{numberOfProducts > 4 && (
<div className="w-full h-full flex flex-col items-center justify-center">
<span className="text-small-regular text-ui-fg-base">
+ {numberOfLines - 4}
</span>
<span className="text-small-regular text-ui-fg-base">more</span>
</div>
)}
</div>
<div className="flex justify-end">
<LocalizedClientLink href={`/account/orders/details/${order.id}`}>
<Button data-testid="order-details-link" variant="secondary">
See details
</Button>
</LocalizedClientLink>
</div>
</div>
)
}
export default OrderCard
@@ -0,0 +1,45 @@
"use client"
import { Button } from "@medusajs/ui"
import OrderCard from "../order-card"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { HttpTypes } from "@medusajs/types"
const OrderOverview = ({ orders }: { orders: HttpTypes.StoreOrder[] }) => {
if (orders?.length) {
return (
<div className="flex flex-col gap-y-8 w-full">
{orders.map((o) => (
<div
key={o.id}
className="border-b border-gray-200 pb-6 last:pb-0 last:border-none"
>
<OrderCard order={o} />
</div>
))}
</div>
)
}
return (
<div
className="w-full flex flex-col items-center gap-y-4"
data-testid="no-orders-container"
>
<h2 className="text-large-semi">Nothing to see here</h2>
<p className="text-base-regular">
You don&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
@@ -0,0 +1,168 @@
import { Container } from "@medusajs/ui"
import ChevronDown from "@modules/common/icons/chevron-down"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { convertToLocale } from "@lib/util/money"
import { HttpTypes } from "@medusajs/types"
type OverviewProps = {
customer: HttpTypes.StoreCustomer | null
orders: HttpTypes.StoreOrder[] | null
}
const Overview = ({ customer, orders }: OverviewProps) => {
return (
<div data-testid="overview-page-wrapper">
<div className="hidden small:block">
<div className="text-xl-semi flex justify-between items-center mb-4">
<span data-testid="welcome-message" data-value={customer?.first_name}>
Hello {customer?.first_name}
</span>
<span className="text-small-regular text-ui-fg-base">
Signed in as:{" "}
<span
className="font-semibold"
data-testid="customer-email"
data-value={customer?.email}
>
{customer?.email}
</span>
</span>
</div>
<div className="flex flex-col py-8 border-t border-gray-200">
<div className="flex flex-col gap-y-4 h-full col-span-1 row-span-2 flex-1">
<div className="flex items-start gap-x-16 mb-6">
<div className="flex flex-col gap-y-4">
<h3 className="text-large-semi">Profile</h3>
<div className="flex items-end gap-x-2">
<span
className="text-3xl-semi leading-none"
data-testid="customer-profile-completion"
data-value={getProfileCompletion(customer)}
>
{getProfileCompletion(customer)}%
</span>
<span className="uppercase text-base-regular text-ui-fg-subtle">
Completed
</span>
</div>
</div>
<div className="flex flex-col gap-y-4">
<h3 className="text-large-semi">Addresses</h3>
<div className="flex items-end gap-x-2">
<span
className="text-3xl-semi leading-none"
data-testid="addresses-count"
data-value={customer?.addresses?.length || 0}
>
{customer?.addresses?.length || 0}
</span>
<span className="uppercase text-base-regular text-ui-fg-subtle">
Saved
</span>
</div>
</div>
</div>
<div className="flex flex-col gap-y-4">
<div className="flex items-center gap-x-2">
<h3 className="text-large-semi">Recent orders</h3>
</div>
<ul
className="flex flex-col gap-y-4"
data-testid="orders-wrapper"
>
{orders && orders.length > 0 ? (
orders.slice(0, 5).map((order) => {
return (
<li
key={order.id}
data-testid="order-wrapper"
data-value={order.id}
>
<LocalizedClientLink
href={`/account/orders/details/${order.id}`}
>
<Container className="bg-gray-50 flex justify-between items-center p-4">
<div className="grid grid-cols-3 grid-rows-2 text-small-regular gap-x-4 flex-1">
<span className="font-semibold">Date placed</span>
<span className="font-semibold">
Order number
</span>
<span className="font-semibold">
Total amount
</span>
<span data-testid="order-created-date">
{new Date(order.created_at).toDateString()}
</span>
<span
data-testid="order-id"
data-value={order.display_id}
>
#{order.display_id}
</span>
<span data-testid="order-amount">
{convertToLocale({
amount: order.total,
currency_code: order.currency_code,
})}
</span>
</div>
<button
className="flex items-center justify-between"
data-testid="open-order-button"
>
<span className="sr-only">
Go to order #{order.display_id}
</span>
<ChevronDown className="-rotate-90" />
</button>
</Container>
</LocalizedClientLink>
</li>
)
})
) : (
<span data-testid="no-orders-message">No recent orders</span>
)}
</ul>
</div>
</div>
</div>
</div>
</div>
)
}
const getProfileCompletion = (customer: HttpTypes.StoreCustomer | null) => {
let count = 0
if (!customer) {
return 0
}
if (customer.email) {
count++
}
if (customer.first_name && customer.last_name) {
count++
}
if (customer.phone) {
count++
}
const billingAddress = customer.addresses?.find(
(addr) => addr.is_default_billing
)
if (billingAddress) {
count++
}
return (count / 4) * 100
}
export default Overview
@@ -0,0 +1,191 @@
"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="Phone"
name="phone"
type="phone"
autoComplete="phone"
required
defaultValue={billingAddress?.phone ?? customer?.phone ?? ""}
data-testid="billing-phone-input"
/>
<Input
label="Address"
name="address_1"
defaultValue={billingAddress?.address_1 || undefined}
required
data-testid="billing-address-1-input"
/>
<Input
label="Apartment, suite, etc."
name="address_2"
defaultValue={billingAddress?.address_2 || undefined}
data-testid="billing-address-2-input"
/>
<div className="grid grid-cols-[144px_1fr] gap-x-2">
<Input
label="Postal code"
name="postal_code"
defaultValue={billingAddress?.postal_code || undefined}
required
data-testid="billing-postcal-code-input"
/>
<Input
label="City"
name="city"
defaultValue={billingAddress?.city || undefined}
required
data-testid="billing-city-input"
/>
</div>
<Input
label="Province"
name="province"
defaultValue={billingAddress?.province || undefined}
data-testid="billing-province-input"
/>
<NativeSelect
name="country_code"
defaultValue={billingAddress?.country_code || undefined}
required
data-testid="billing-country-code-select"
>
<option value="">-</option>
{regionOptions.map((option, i) => {
return (
<option key={i} value={option?.value}>
{option?.label}
</option>
)
})}
</NativeSelect>
</div>
</AccountInfo>
</form>
)
}
export default ProfileBillingAddress
@@ -0,0 +1,75 @@
"use client"
import React, { useEffect, useActionState } from "react";
import Input from "@modules/common/components/input"
import AccountInfo from "../account-info"
import { HttpTypes } from "@medusajs/types"
// import { updateCustomer } from "@lib/data/customer"
type MyInformationProps = {
customer: HttpTypes.StoreCustomer
}
const ProfileEmail: React.FC<MyInformationProps> = ({ customer }) => {
const [successState, setSuccessState] = React.useState(false)
// TODO: It seems we don't support updating emails now?
const updateCustomerEmail = (
_currentState: Record<string, unknown>,
formData: FormData
) => {
const customer = {
email: formData.get("email") as string,
}
try {
// await updateCustomer(customer)
return { success: true, error: null }
} catch (error: any) {
return { success: false, error: error.toString() }
}
}
const [state, formAction] = useActionState(updateCustomerEmail, {
error: false,
success: false,
})
const clearState = () => {
setSuccessState(false)
}
useEffect(() => {
setSuccessState(state.success)
}, [state])
return (
<form action={formAction} className="w-full">
<AccountInfo
label="Email"
currentInfo={`${customer.email}`}
isSuccess={successState}
isError={!!state.error}
errorMessage={state.error}
clearState={clearState}
data-testid="account-email-editor"
>
<div className="grid grid-cols-1 gap-y-2">
<Input
label="Email"
name="email"
type="email"
autoComplete="email"
required
defaultValue={customer.email}
data-testid="email-input"
/>
</div>
</AccountInfo>
</form>
)
}
export default ProfileEmail
@@ -0,0 +1,79 @@
"use client"
import React, { useEffect, useActionState } from "react";
import Input from "@modules/common/components/input"
import AccountInfo from "../account-info"
import { HttpTypes } from "@medusajs/types"
import { updateCustomer } from "@lib/data/customer"
type MyInformationProps = {
customer: HttpTypes.StoreCustomer
}
const ProfileName: React.FC<MyInformationProps> = ({ customer }) => {
const [successState, setSuccessState] = React.useState(false)
const updateCustomerName = async (
_currentState: Record<string, unknown>,
formData: FormData
) => {
const customer = {
first_name: formData.get("first_name") as string,
last_name: formData.get("last_name") as string,
}
try {
await updateCustomer(customer)
return { success: true, error: null }
} catch (error: any) {
return { success: false, error: error.toString() }
}
}
const [state, formAction] = useActionState(updateCustomerName, {
error: false,
success: false,
})
const clearState = () => {
setSuccessState(false)
}
useEffect(() => {
setSuccessState(state.success)
}, [state])
return (
<form action={formAction} className="w-full overflow-visible">
<AccountInfo
label="Name"
currentInfo={`${customer.first_name} ${customer.last_name}`}
isSuccess={successState}
isError={!!state?.error}
clearState={clearState}
data-testid="account-name-editor"
>
<div className="grid grid-cols-2 gap-x-4">
<Input
label="First name"
name="first_name"
required
defaultValue={customer.first_name ?? ""}
data-testid="first-name-input"
/>
<Input
label="Last name"
name="last_name"
required
defaultValue={customer.last_name ?? ""}
data-testid="last-name-input"
/>
</div>
</AccountInfo>
</form>
)
}
export default ProfileName
@@ -0,0 +1,70 @@
"use client"
import React, { useEffect, useActionState } from "react"
import Input from "@modules/common/components/input"
import AccountInfo from "../account-info"
import { HttpTypes } from "@medusajs/types"
import { toast } from "@medusajs/ui"
type MyInformationProps = {
customer: HttpTypes.StoreCustomer
}
const ProfilePassword: React.FC<MyInformationProps> = ({ customer }) => {
const [successState, setSuccessState] = React.useState(false)
// TODO: Add support for password updates
const updatePassword = async () => {
toast.info("Password update is not implemented")
}
const clearState = () => {
setSuccessState(false)
}
return (
<form
action={updatePassword}
onReset={() => clearState()}
className="w-full"
>
<AccountInfo
label="Password"
currentInfo={
<span>The password is not shown for security reasons</span>
}
isSuccess={successState}
isError={false}
errorMessage={undefined}
clearState={clearState}
data-testid="account-password-editor"
>
<div className="grid grid-cols-2 gap-4">
<Input
label="Old password"
name="old_password"
required
type="password"
data-testid="old-password-input"
/>
<Input
label="New password"
type="password"
name="new_password"
required
data-testid="new-password-input"
/>
<Input
label="Confirm password"
type="password"
name="confirm_password"
required
data-testid="confirm-password-input"
/>
</div>
</AccountInfo>
</form>
)
}
export default ProfilePassword
@@ -0,0 +1,74 @@
"use client"
import React, { useEffect, useActionState } from "react";
import Input from "@modules/common/components/input"
import AccountInfo from "../account-info"
import { HttpTypes } from "@medusajs/types"
import { updateCustomer } from "@lib/data/customer"
type MyInformationProps = {
customer: HttpTypes.StoreCustomer
}
const ProfileEmail: React.FC<MyInformationProps> = ({ customer }) => {
const [successState, setSuccessState] = React.useState(false)
const updateCustomerPhone = async (
_currentState: Record<string, unknown>,
formData: FormData
) => {
const customer = {
phone: formData.get("phone") as string,
}
try {
await updateCustomer(customer)
return { success: true, error: null }
} catch (error: any) {
return { success: false, error: error.toString() }
}
}
const [state, formAction] = useActionState(updateCustomerPhone, {
error: false,
success: false,
})
const clearState = () => {
setSuccessState(false)
}
useEffect(() => {
setSuccessState(state.success)
}, [state])
return (
<form action={formAction} className="w-full">
<AccountInfo
label="Phone"
currentInfo={`${customer.phone}`}
isSuccess={successState}
isError={!!state.error}
errorMessage={state.error}
clearState={clearState}
data-testid="account-phone-editor"
>
<div className="grid grid-cols-1 gap-y-2">
<Input
label="Phone"
name="phone"
type="phone"
autoComplete="phone"
required
defaultValue={customer.phone ?? ""}
data-testid="phone-input"
/>
</div>
</AccountInfo>
</form>
)
}
export default ProfileEmail
@@ -0,0 +1,106 @@
"use client"
import { useActionState } from "react"
import Input from "@modules/common/components/input"
import { LOGIN_VIEW } from "@modules/account/templates/login-template"
import ErrorMessage from "@modules/checkout/components/error-message"
import { SubmitButton } from "@modules/checkout/components/submit-button"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { signup } from "@lib/data/customer"
type Props = {
setCurrentView: (view: LOGIN_VIEW) => void
}
const Register = ({ setCurrentView }: Props) => {
const [message, formAction] = useActionState(signup, null)
return (
<div
className="max-w-sm flex flex-col items-center"
data-testid="register-page"
>
<h1 className="text-large-semi uppercase mb-6">
Become a Medusa Store Member
</h1>
<p className="text-center text-base-regular text-ui-fg-base mb-4">
Create your Medusa Store Member profile, and get access to an enhanced
shopping experience.
</p>
<form className="w-full flex flex-col" action={formAction}>
<div className="flex flex-col w-full gap-y-2">
<Input
label="First name"
name="first_name"
required
autoComplete="given-name"
data-testid="first-name-input"
/>
<Input
label="Last name"
name="last_name"
required
autoComplete="family-name"
data-testid="last-name-input"
/>
<Input
label="Email"
name="email"
required
type="email"
autoComplete="email"
data-testid="email-input"
/>
<Input
label="Phone"
name="phone"
type="tel"
autoComplete="tel"
data-testid="phone-input"
/>
<Input
label="Password"
name="password"
required
type="password"
autoComplete="new-password"
data-testid="password-input"
/>
</div>
<ErrorMessage error={message} data-testid="register-error" />
<span className="text-center text-ui-fg-base text-small-regular mt-6">
By creating an account, you agree to Medusa Store&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
@@ -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>
)
}
@@ -0,0 +1,43 @@
import React from "react"
import UnderlineLink from "@modules/common/components/interactive-link"
import AccountNav from "../components/account-nav"
import { HttpTypes } from "@medusajs/types"
interface AccountLayoutProps {
customer: HttpTypes.StoreCustomer | null
children: React.ReactNode
}
const AccountLayout: React.FC<AccountLayoutProps> = ({
customer,
children,
}) => {
return (
<div className="flex-1 small:py-12" data-testid="account-page">
<div className="flex-1 content-container h-full max-w-5xl mx-auto bg-white flex flex-col">
<div className="grid grid-cols-1 small:grid-cols-[240px_1fr] py-12">
<div>{customer && <AccountNav customer={customer} />}</div>
<div className="flex-1">{children}</div>
</div>
<div className="flex flex-col small:flex-row items-end justify-between small:border-t border-gray-200 py-12 gap-8">
<div>
<h3 className="text-xl-semi mb-4">Got questions?</h3>
<span className="txt-medium">
You can find frequently asked questions and answers on our
customer service page.
</span>
</div>
<div>
<UnderlineLink href="/customer-service">
Customer Service
</UnderlineLink>
</div>
</div>
</div>
</div>
)
}
export default AccountLayout
@@ -0,0 +1,27 @@
"use client"
import { useState } from "react"
import Register from "@modules/account/components/register"
import Login from "@modules/account/components/login"
export enum LOGIN_VIEW {
SIGN_IN = "sign-in",
REGISTER = "register",
}
const LoginTemplate = () => {
const [currentView, setCurrentView] = useState("sign-in")
return (
<div className="w-full flex justify-start px-8 py-8">
{currentView === "sign-in" ? (
<Login setCurrentView={setCurrentView} />
) : (
<Register setCurrentView={setCurrentView} />
)}
</div>
)
}
export default LoginTemplate
@@ -0,0 +1,73 @@
"use client"
import { IconBadge, clx } from "@medusajs/ui"
import {
SelectHTMLAttributes,
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import ChevronDown from "@modules/common/icons/chevron-down"
type NativeSelectProps = {
placeholder?: string
errors?: Record<string, unknown>
touched?: Record<string, unknown>
} & Omit<SelectHTMLAttributes<HTMLSelectElement>, "size">
const CartItemSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
({ placeholder = "Select...", className, children, ...props }, ref) => {
const innerRef = useRef<HTMLSelectElement>(null)
const [isPlaceholder, setIsPlaceholder] = useState(false)
useImperativeHandle<HTMLSelectElement | null, HTMLSelectElement | null>(
ref,
() => innerRef.current
)
useEffect(() => {
if (innerRef.current && innerRef.current.value === "") {
setIsPlaceholder(true)
} else {
setIsPlaceholder(false)
}
}, [innerRef.current?.value])
return (
<div>
<IconBadge
onFocus={() => innerRef.current?.focus()}
onBlur={() => innerRef.current?.blur()}
className={clx(
"relative flex items-center txt-compact-small border text-ui-fg-base group",
className,
{
"text-ui-fg-subtle": isPlaceholder,
}
)}
>
<select
ref={innerRef}
{...props}
className="appearance-none bg-transparent border-none px-4 transition-colors duration-150 focus:border-gray-700 outline-none w-16 h-16 items-center justify-center"
>
<option disabled value="">
{placeholder}
</option>
{children}
</select>
<span className="absolute flex pointer-events-none justify-end w-8 group-hover:animate-pulse">
<ChevronDown />
</span>
</IconBadge>
</div>
)
}
)
CartItemSelect.displayName = "CartItemSelect"
export default CartItemSelect
@@ -0,0 +1,25 @@
import { Heading, Text } from "@medusajs/ui"
import InteractiveLink from "@modules/common/components/interactive-link"
const EmptyCartMessage = () => {
return (
<div className="py-48 px-2 flex flex-col justify-center items-start" data-testid="empty-cart-message">
<Heading
level="h1"
className="flex flex-row text-3xl-regular gap-x-2 items-baseline"
>
Cart
</Heading>
<Text className="text-base-regular mt-4 mb-6 max-w-[32rem]">
You don&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
+144
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
@@ -0,0 +1,26 @@
import { Button, Heading, Text } from "@medusajs/ui"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
const SignInPrompt = () => {
return (
<div className="bg-white flex items-center justify-between">
<div>
<Heading level="h2" className="txt-xlarge">
Already have an account?
</Heading>
<Text className="txt-medium text-ui-fg-subtle mt-2">
Sign in for a better experience.
</Text>
</div>
<div>
<LocalizedClientLink href="/account">
<Button variant="secondary" className="h-10" data-testid="sign-in-button">
Sign in
</Button>
</LocalizedClientLink>
</div>
</div>
)
}
export default SignInPrompt
+51
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
+57
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
+51
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
+48
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
@@ -0,0 +1,97 @@
import { notFound } from "next/navigation"
import { Suspense } from "react"
import InteractiveLink from "@modules/common/components/interactive-link"
import SkeletonProductGrid from "@modules/skeletons/templates/skeleton-product-grid"
import RefinementList from "@modules/store/components/refinement-list"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
import PaginatedProducts from "@modules/store/templates/paginated-products"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { HttpTypes } from "@medusajs/types"
export default function CategoryTemplate({
category,
sortBy,
page,
countryCode,
}: {
category: HttpTypes.StoreProductCategory
sortBy?: SortOptions
page?: string
countryCode: string
}) {
const pageNumber = page ? parseInt(page) : 1
const sort = sortBy || "created_at"
if (!category || !countryCode) notFound()
const parents = [] as HttpTypes.StoreProductCategory[]
const getParents = (category: HttpTypes.StoreProductCategory) => {
if (category.parent_category) {
parents.push(category.parent_category)
getParents(category.parent_category)
}
}
getParents(category)
return (
<div
className="flex flex-col small:flex-row small:items-start py-6 content-container"
data-testid="category-container"
>
<RefinementList sortBy={sort} data-testid="sort-by-container" />
<div className="w-full">
<div className="flex flex-row mb-8 text-2xl-semi gap-4">
{parents &&
parents.map((parent) => (
<span key={parent.id} className="text-ui-fg-subtle">
<LocalizedClientLink
className="mr-4 hover:text-black"
href={`/categories/${parent.handle}`}
data-testid="sort-by-link"
>
{parent.name}
</LocalizedClientLink>
/
</span>
))}
<h1 data-testid="category-page-title">{category.name}</h1>
</div>
{category.description && (
<div className="mb-8 text-base-regular">
<p>{category.description}</p>
</div>
)}
{category.category_children && (
<div className="mb-8 text-base-large">
<ul className="grid grid-cols-1 gap-2">
{category.category_children?.map((c) => (
<li key={c.id}>
<InteractiveLink href={`/categories/${c.handle}`}>
{c.name}
</InteractiveLink>
</li>
))}
</ul>
</div>
)}
<Suspense
fallback={
<SkeletonProductGrid
numberOfProducts={category.products?.length ?? 8}
/>
}
>
<PaginatedProducts
sortBy={sort}
page={pageNumber}
categoryId={category.id}
countryCode={countryCode}
/>
</Suspense>
</div>
</div>
)
}
@@ -0,0 +1,116 @@
import { Listbox, Transition } from "@headlessui/react"
import { ChevronUpDown } from "@medusajs/icons"
import { clx } from "@medusajs/ui"
import { Fragment, useMemo } from "react"
import Radio from "@modules/common/components/radio"
import compareAddresses from "@lib/util/compare-addresses"
import { HttpTypes } from "@medusajs/types"
type AddressSelectProps = {
addresses: HttpTypes.StoreCustomerAddress[]
addressInput: HttpTypes.StoreCartAddress | null
onSelect: (
address: HttpTypes.StoreCartAddress | undefined,
email?: string
) => void
}
const AddressSelect = ({
addresses,
addressInput,
onSelect,
}: AddressSelectProps) => {
const handleSelect = (id: string) => {
const savedAddress = addresses.find((a) => a.id === id)
if (savedAddress) {
onSelect(savedAddress as HttpTypes.StoreCartAddress)
}
}
const selectedAddress = useMemo(() => {
return addresses.find((a) => compareAddresses(a, addressInput))
}, [addresses, addressInput])
return (
<Listbox onChange={handleSelect} value={selectedAddress?.id}>
<div className="relative">
<Listbox.Button
className="relative w-full flex justify-between items-center px-4 py-[10px] text-left bg-white cursor-default focus:outline-none border rounded-rounded focus-visible:ring-2 focus-visible:ring-opacity-75 focus-visible:ring-white focus-visible:ring-offset-gray-300 focus-visible:ring-offset-2 focus-visible:border-gray-300 text-base-regular"
data-testid="shipping-address-select"
>
{({ open }) => (
<>
<span className="block truncate">
{selectedAddress
? selectedAddress.address_1
: "Choose an address"}
</span>
<ChevronUpDown
className={clx("transition-rotate duration-200", {
"transform rotate-180": open,
})}
/>
</>
)}
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options
className="absolute z-20 w-full overflow-auto text-small-regular bg-white border border-top-0 max-h-60 focus:outline-none sm:text-sm"
data-testid="shipping-address-options"
>
{addresses.map((address) => {
return (
<Listbox.Option
key={address.id}
value={address.id}
className="cursor-default select-none relative pl-6 pr-10 hover:bg-gray-50 py-4"
data-testid="shipping-address-option"
>
<div className="flex gap-x-4 items-start">
<Radio
checked={selectedAddress?.id === address.id}
data-testid="shipping-address-radio"
/>
<div className="flex flex-col">
<span className="text-left text-base-semi">
{address.first_name} {address.last_name}
</span>
{address.company && (
<span className="text-small-regular text-ui-fg-base">
{address.company}
</span>
)}
<div className="flex flex-col text-left text-base-regular mt-2">
<span>
{address.address_1}
{address.address_2 && (
<span>, {address.address_2}</span>
)}
</span>
<span>
{address.postal_code}, {address.city}
</span>
<span>
{address.province && `${address.province}, `}
{address.country_code?.toUpperCase()}
</span>
</div>
</div>
</div>
</Listbox.Option>
)
})}
</Listbox.Options>
</Transition>
</div>
</Listbox>
)
}
export default AddressSelect
@@ -0,0 +1,184 @@
"use client"
import { setAddresses } from "@lib/data/cart"
import compareAddresses from "@lib/util/compare-addresses"
import { CheckCircleSolid } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Heading, Text, useToggleState } from "@medusajs/ui"
import Divider from "@modules/common/components/divider"
import Spinner from "@modules/common/icons/spinner"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useActionState } from "react"
import BillingAddress from "../billing_address"
import ErrorMessage from "../error-message"
import ShippingAddress from "../shipping-address"
import { SubmitButton } from "../submit-button"
const Addresses = ({
cart,
customer,
}: {
cart: HttpTypes.StoreCart | null
customer: HttpTypes.StoreCustomer | null
}) => {
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const isOpen = searchParams.get("step") === "address"
const { state: sameAsBilling, toggle: toggleSameAsBilling } = useToggleState(
cart?.shipping_address && cart?.billing_address
? compareAddresses(cart?.shipping_address, cart?.billing_address)
: true
)
const handleEdit = () => {
router.push(pathname + "?step=address")
}
const [message, formAction] = useActionState(setAddresses, null)
return (
<div className="bg-white">
<div className="flex flex-row items-center justify-between mb-6">
<Heading
level="h2"
className="flex flex-row text-3xl-regular gap-x-2 items-baseline"
>
Shipping Address
{!isOpen && <CheckCircleSolid />}
</Heading>
{!isOpen && cart?.shipping_address && (
<Text>
<button
onClick={handleEdit}
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
data-testid="edit-address-button"
>
Edit
</button>
</Text>
)}
</div>
{isOpen ? (
<form action={formAction}>
<div className="pb-8">
<ShippingAddress
customer={customer}
checked={sameAsBilling}
onChange={toggleSameAsBilling}
cart={cart}
/>
{!sameAsBilling && (
<div>
<Heading
level="h2"
className="text-3xl-regular gap-x-4 pb-6 pt-8"
>
Billing address
</Heading>
<BillingAddress cart={cart} />
</div>
)}
<SubmitButton className="mt-6" data-testid="submit-address-button">
Continue to delivery
</SubmitButton>
<ErrorMessage error={message} data-testid="address-error-message" />
</div>
</form>
) : (
<div>
<div className="text-small-regular">
{cart && cart.shipping_address ? (
<div className="flex items-start gap-x-8">
<div className="flex items-start gap-x-1 w-full">
<div
className="flex flex-col w-1/3"
data-testid="shipping-address-summary"
>
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Shipping Address
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.shipping_address.first_name}{" "}
{cart.shipping_address.last_name}
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.shipping_address.address_1}{" "}
{cart.shipping_address.address_2}
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.shipping_address.postal_code},{" "}
{cart.shipping_address.city}
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.shipping_address.country_code?.toUpperCase()}
</Text>
</div>
<div
className="flex flex-col w-1/3 "
data-testid="shipping-contact-summary"
>
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Contact
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.shipping_address.phone}
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.email}
</Text>
</div>
<div
className="flex flex-col w-1/3"
data-testid="billing-address-summary"
>
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Billing Address
</Text>
{sameAsBilling ? (
<Text className="txt-medium text-ui-fg-subtle">
Billing and delivery address are the same.
</Text>
) : (
<>
<Text className="txt-medium text-ui-fg-subtle">
{cart.billing_address?.first_name}{" "}
{cart.billing_address?.last_name}
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.billing_address?.address_1}{" "}
{cart.billing_address?.address_2}
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.billing_address?.postal_code},{" "}
{cart.billing_address?.city}
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.billing_address?.country_code?.toUpperCase()}
</Text>
</>
)}
</div>
</div>
</div>
) : (
<div>
<Spinner />
</div>
)}
</div>
</div>
)}
<Divider className="mt-8" />
</div>
)
}
export default Addresses
@@ -0,0 +1,114 @@
import { HttpTypes } from "@medusajs/types"
import Input from "@modules/common/components/input"
import React, { useState } from "react"
import CountrySelect from "../country-select"
const BillingAddress = ({ cart }: { cart: HttpTypes.StoreCart | null }) => {
const [formData, setFormData] = useState<any>({
"billing_address.first_name": cart?.billing_address?.first_name || "",
"billing_address.last_name": cart?.billing_address?.last_name || "",
"billing_address.address_1": cart?.billing_address?.address_1 || "",
"billing_address.company": cart?.billing_address?.company || "",
"billing_address.postal_code": cart?.billing_address?.postal_code || "",
"billing_address.city": cart?.billing_address?.city || "",
"billing_address.country_code": cart?.billing_address?.country_code || "",
"billing_address.province": cart?.billing_address?.province || "",
"billing_address.phone": cart?.billing_address?.phone || "",
})
const handleChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLInputElement | HTMLSelectElement
>
) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
})
}
return (
<>
<div className="grid grid-cols-2 gap-4">
<Input
label="First name"
name="billing_address.first_name"
autoComplete="given-name"
value={formData["billing_address.first_name"]}
onChange={handleChange}
required
data-testid="billing-first-name-input"
/>
<Input
label="Last name"
name="billing_address.last_name"
autoComplete="family-name"
value={formData["billing_address.last_name"]}
onChange={handleChange}
required
data-testid="billing-last-name-input"
/>
<Input
label="Address"
name="billing_address.address_1"
autoComplete="address-line1"
value={formData["billing_address.address_1"]}
onChange={handleChange}
required
data-testid="billing-address-input"
/>
<Input
label="Company"
name="billing_address.company"
value={formData["billing_address.company"]}
onChange={handleChange}
autoComplete="organization"
data-testid="billing-company-input"
/>
<Input
label="Postal code"
name="billing_address.postal_code"
autoComplete="postal-code"
value={formData["billing_address.postal_code"]}
onChange={handleChange}
required
data-testid="billing-postal-input"
/>
<Input
label="City"
name="billing_address.city"
autoComplete="address-level2"
value={formData["billing_address.city"]}
onChange={handleChange}
/>
<CountrySelect
name="billing_address.country_code"
autoComplete="country"
region={cart?.region}
value={formData["billing_address.country_code"]}
onChange={handleChange}
required
data-testid="billing-country-select"
/>
<Input
label="State / Province"
name="billing_address.province"
autoComplete="address-level1"
value={formData["billing_address.province"]}
onChange={handleChange}
data-testid="billing-province-input"
/>
<Input
label="Phone"
name="billing_address.phone"
autoComplete="tel"
value={formData["billing_address.phone"]}
onChange={handleChange}
data-testid="billing-phone-input"
/>
</div>
</>
)
}
export default BillingAddress
@@ -0,0 +1,50 @@
import { forwardRef, useImperativeHandle, useMemo, useRef } from "react"
import NativeSelect, {
NativeSelectProps,
} from "@modules/common/components/native-select"
import { HttpTypes } from "@medusajs/types"
const CountrySelect = forwardRef<
HTMLSelectElement,
NativeSelectProps & {
region?: HttpTypes.StoreRegion
}
>(({ placeholder = "Country", region, defaultValue, ...props }, ref) => {
const innerRef = useRef<HTMLSelectElement>(null)
useImperativeHandle<HTMLSelectElement | null, HTMLSelectElement | null>(
ref,
() => innerRef.current
)
const countryOptions = useMemo(() => {
if (!region) {
return []
}
return region.countries?.map((country) => ({
value: country.iso_2,
label: country.display_name,
}))
}, [region])
return (
<NativeSelect
ref={innerRef}
placeholder={placeholder}
defaultValue={defaultValue}
{...props}
>
{countryOptions?.map(({ value, label }, index) => (
<option key={index} value={value}>
{label}
</option>
))}
</NativeSelect>
)
})
CountrySelect.displayName = "CountrySelect"
export default CountrySelect
@@ -0,0 +1,179 @@
"use client"
import { Badge, Heading, Input, Label, Text } from "@medusajs/ui"
import React from "react"
import { applyPromotions } from "@lib/data/cart"
import { convertToLocale } from "@lib/util/money"
import { HttpTypes } from "@medusajs/types"
import Trash from "@modules/common/icons/trash"
import ErrorMessage from "../error-message"
import { SubmitButton } from "../submit-button"
type DiscountCodeProps = {
cart: HttpTypes.StoreCart & {
promotions: HttpTypes.StorePromotion[]
}
}
const DiscountCode: React.FC<DiscountCodeProps> = ({ cart }) => {
const [isOpen, setIsOpen] = React.useState(false)
const [errorMessage, setErrorMessage] = React.useState("")
const { promotions = [] } = cart
const removePromotionCode = async (code: string) => {
const validPromotions = promotions.filter(
(promotion) => promotion.code !== code
)
await applyPromotions(
validPromotions.filter((p) => p.code !== undefined).map((p) => p.code!)
)
}
const addPromotionCode = async (formData: FormData) => {
setErrorMessage("")
const code = formData.get("code")
if (!code) {
return
}
const input = document.getElementById("promotion-input") as HTMLInputElement
const codes = promotions
.filter((p) => p.code !== undefined)
.map((p) => p.code!)
codes.push(code.toString())
try {
await applyPromotions(codes)
} catch (e: any) {
setErrorMessage(e.message)
}
if (input) {
input.value = ""
}
}
return (
<div className="w-full bg-white flex flex-col">
<div className="txt-medium">
<form action={(a) => addPromotionCode(a)} className="w-full mb-5">
<Label className="flex gap-x-1 my-2 items-center">
<button
onClick={() => setIsOpen(!isOpen)}
type="button"
className="txt-medium text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
data-testid="add-discount-button"
>
Add Promotion Code(s)
</button>
{/* <Tooltip content="You can add multiple promotion codes">
<InformationCircleSolid color="var(--fg-muted)" />
</Tooltip> */}
</Label>
{isOpen && (
<>
<div className="flex w-full gap-x-2">
<Input
className="size-full"
id="promotion-input"
name="code"
type="text"
autoFocus={false}
data-testid="discount-input"
/>
<SubmitButton
variant="secondary"
data-testid="discount-apply-button"
>
Apply
</SubmitButton>
</div>
<ErrorMessage
error={errorMessage}
data-testid="discount-error-message"
/>
</>
)}
</form>
{promotions.length > 0 && (
<div className="w-full flex items-center">
<div className="flex flex-col w-full">
<Heading className="txt-medium mb-2">
Promotion(s) applied:
</Heading>
{promotions.map((promotion) => {
return (
<div
key={promotion.id}
className="flex items-center justify-between w-full max-w-full mb-2"
data-testid="discount-row"
>
<Text className="flex gap-x-1 items-baseline txt-small-plus w-4/5 pr-1">
<span className="truncate" data-testid="discount-code">
<Badge
color={promotion.is_automatic ? "green" : "grey"}
size="small"
>
{promotion.code}
</Badge>{" "}
(
{promotion.application_method?.value !== undefined &&
promotion.application_method.currency_code !==
undefined && (
<>
{promotion.application_method.type ===
"percentage"
? `${promotion.application_method.value}%`
: convertToLocale({
amount: +promotion.application_method.value,
currency_code:
promotion.application_method
.currency_code,
})}
</>
)}
)
{/* {promotion.is_automatic && (
<Tooltip content="This promotion is automatically applied">
<InformationCircleSolid className="inline text-zinc-400" />
</Tooltip>
)} */}
</span>
</Text>
{!promotion.is_automatic && (
<button
className="flex items-center"
onClick={() => {
if (!promotion.code) {
return
}
removePromotionCode(promotion.code)
}}
data-testid="remove-discount-button"
>
<Trash size={14} />
<span className="sr-only">
Remove discount code from order
</span>
</button>
)}
</div>
)
})}
</div>
</div>
)}
</div>
</div>
)
}
export default DiscountCode
@@ -0,0 +1,13 @@
const ErrorMessage = ({ error, 'data-testid': dataTestid }: { error?: string | null, 'data-testid'?: string }) => {
if (!error) {
return null
}
return (
<div className="pt-2 text-rose-500 text-small-regular" data-testid={dataTestid}>
<span>{error}</span>
</div>
)
}
export default ErrorMessage
@@ -0,0 +1,193 @@
"use client"
import { isManual, isStripe } from "@lib/constants"
import { placeOrder } from "@lib/data/cart"
import { HttpTypes } from "@medusajs/types"
import { Button } from "@medusajs/ui"
import { useElements, useStripe } from "@stripe/react-stripe-js"
import React, { useState } from "react"
import ErrorMessage from "../error-message"
type PaymentButtonProps = {
cart: HttpTypes.StoreCart
"data-testid": string
}
const PaymentButton: React.FC<PaymentButtonProps> = ({
cart,
"data-testid": dataTestId,
}) => {
const notReady =
!cart ||
!cart.shipping_address ||
!cart.billing_address ||
!cart.email ||
(cart.shipping_methods?.length ?? 0) < 1
const paymentSession = cart.payment_collection?.payment_sessions?.[0]
switch (true) {
case isStripe(paymentSession?.provider_id):
return (
<StripePaymentButton
notReady={notReady}
cart={cart}
data-testid={dataTestId}
/>
)
case isManual(paymentSession?.provider_id):
return (
<ManualTestPaymentButton notReady={notReady} data-testid={dataTestId} />
)
default:
return <Button disabled>Select a payment method</Button>
}
}
const StripePaymentButton = ({
cart,
notReady,
"data-testid": dataTestId,
}: {
cart: HttpTypes.StoreCart
notReady: boolean
"data-testid"?: string
}) => {
const [submitting, setSubmitting] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const onPaymentCompleted = async () => {
await placeOrder()
.catch((err) => {
setErrorMessage(err.message)
})
.finally(() => {
setSubmitting(false)
})
}
const stripe = useStripe()
const elements = useElements()
const card = elements?.getElement("card")
const session = cart.payment_collection?.payment_sessions?.find(
(s) => s.status === "pending"
)
const disabled = !stripe || !elements ? true : false
const handlePayment = async () => {
setSubmitting(true)
if (!stripe || !elements || !card || !cart) {
setSubmitting(false)
return
}
await stripe
.confirmCardPayment(session?.data.client_secret as string, {
payment_method: {
card: card,
billing_details: {
name:
cart.billing_address?.first_name +
" " +
cart.billing_address?.last_name,
address: {
city: cart.billing_address?.city ?? undefined,
country: cart.billing_address?.country_code ?? undefined,
line1: cart.billing_address?.address_1 ?? undefined,
line2: cart.billing_address?.address_2 ?? undefined,
postal_code: cart.billing_address?.postal_code ?? undefined,
state: cart.billing_address?.province ?? undefined,
},
email: cart.email,
phone: cart.billing_address?.phone ?? undefined,
},
},
})
.then(({ error, paymentIntent }) => {
if (error) {
const pi = error.payment_intent
if (
(pi && pi.status === "requires_capture") ||
(pi && pi.status === "succeeded")
) {
onPaymentCompleted()
}
setErrorMessage(error.message || null)
return
}
if (
(paymentIntent && paymentIntent.status === "requires_capture") ||
paymentIntent.status === "succeeded"
) {
return onPaymentCompleted()
}
return
})
}
return (
<>
<Button
disabled={disabled || notReady}
onClick={handlePayment}
size="large"
isLoading={submitting}
data-testid={dataTestId}
>
Place order
</Button>
<ErrorMessage
error={errorMessage}
data-testid="stripe-payment-error-message"
/>
</>
)
}
const ManualTestPaymentButton = ({ notReady }: { notReady: boolean }) => {
const [submitting, setSubmitting] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const onPaymentCompleted = async () => {
await placeOrder()
.catch((err) => {
setErrorMessage(err.message)
})
.finally(() => {
setSubmitting(false)
})
}
const handlePayment = () => {
setSubmitting(true)
onPaymentCompleted()
}
return (
<>
<Button
disabled={notReady}
isLoading={submitting}
onClick={handlePayment}
size="large"
data-testid="submit-order-button"
>
Place order
</Button>
<ErrorMessage
error={errorMessage}
data-testid="manual-payment-error-message"
/>
</>
)
}
export default PaymentButton
@@ -0,0 +1,129 @@
import { Radio as RadioGroupOption } from "@headlessui/react"
import { Text, clx } from "@medusajs/ui"
import React, { useContext, useMemo, type JSX } from "react"
import Radio from "@modules/common/components/radio"
import { isManual } from "@lib/constants"
import SkeletonCardDetails from "@modules/skeletons/components/skeleton-card-details"
import { CardElement } from "@stripe/react-stripe-js"
import { StripeCardElementOptions } from "@stripe/stripe-js"
import PaymentTest from "../payment-test"
import { StripeContext } from "../payment-wrapper/stripe-wrapper"
type PaymentContainerProps = {
paymentProviderId: string
selectedPaymentOptionId: string | null
disabled?: boolean
paymentInfoMap: Record<string, { title: string; icon: JSX.Element }>
children?: React.ReactNode
}
const PaymentContainer: React.FC<PaymentContainerProps> = ({
paymentProviderId,
selectedPaymentOptionId,
paymentInfoMap,
disabled = false,
children,
}) => {
const isDevelopment = process.env.NODE_ENV === "development"
return (
<RadioGroupOption
key={paymentProviderId}
value={paymentProviderId}
disabled={disabled}
className={clx(
"flex flex-col gap-y-2 text-small-regular cursor-pointer py-4 border rounded-rounded px-8 mb-2 hover:shadow-borders-interactive-with-active",
{
"border-ui-border-interactive":
selectedPaymentOptionId === paymentProviderId,
}
)}
>
<div className="flex items-center justify-between ">
<div className="flex items-center gap-x-4">
<Radio checked={selectedPaymentOptionId === paymentProviderId} />
<Text className="text-base-regular">
{paymentInfoMap[paymentProviderId]?.title || paymentProviderId}
</Text>
{isManual(paymentProviderId) && isDevelopment && (
<PaymentTest className="hidden small:block" />
)}
</div>
<span className="justify-self-end text-ui-fg-base">
{paymentInfoMap[paymentProviderId]?.icon}
</span>
</div>
{isManual(paymentProviderId) && isDevelopment && (
<PaymentTest className="small:hidden text-[10px]" />
)}
{children}
</RadioGroupOption>
)
}
export default PaymentContainer
export const StripeCardContainer = ({
paymentProviderId,
selectedPaymentOptionId,
paymentInfoMap,
disabled = false,
setCardBrand,
setError,
setCardComplete,
}: Omit<PaymentContainerProps, "children"> & {
setCardBrand: (brand: string) => void
setError: (error: string | null) => void
setCardComplete: (complete: boolean) => void
}) => {
const stripeReady = useContext(StripeContext)
const useOptions: StripeCardElementOptions = useMemo(() => {
return {
style: {
base: {
fontFamily: "Inter, sans-serif",
color: "#424270",
"::placeholder": {
color: "rgb(107 114 128)",
},
},
},
classes: {
base: "pt-3 pb-1 block w-full h-11 px-4 mt-0 bg-ui-bg-field border rounded-md appearance-none focus:outline-none focus:ring-0 focus:shadow-borders-interactive-with-active border-ui-border-base hover:bg-ui-bg-field-hover transition-all duration-300 ease-in-out",
},
}
}, [])
return (
<PaymentContainer
paymentProviderId={paymentProviderId}
selectedPaymentOptionId={selectedPaymentOptionId}
paymentInfoMap={paymentInfoMap}
disabled={disabled}
>
{selectedPaymentOptionId === paymentProviderId &&
(stripeReady ? (
<div className="my-4 transition-all duration-150 ease-in-out">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Enter your card details:
</Text>
<CardElement
options={useOptions as StripeCardElementOptions}
onChange={(e) => {
setCardBrand(
e.brand && e.brand.charAt(0).toUpperCase() + e.brand.slice(1)
)
setError(e.error?.message || null)
setCardComplete(e.complete)
}}
/>
</div>
) : (
<SkeletonCardDetails />
))}
</PaymentContainer>
)
}
@@ -0,0 +1,12 @@
import { Badge } from "@medusajs/ui"
const PaymentTest = ({ className }: { className?: string }) => {
return (
<Badge color="orange" className={className}>
<span className="font-semibold">Attention:</span> For testing purposes
only.
</Badge>
)
}
export default PaymentTest
@@ -0,0 +1,41 @@
"use client"
import { loadStripe } from "@stripe/stripe-js"
import React from "react"
import StripeWrapper from "./stripe-wrapper"
import { HttpTypes } from "@medusajs/types"
import { isStripe } from "@lib/constants"
type PaymentWrapperProps = {
cart: HttpTypes.StoreCart
children: React.ReactNode
}
const stripeKey = process.env.NEXT_PUBLIC_STRIPE_KEY
const stripePromise = stripeKey ? loadStripe(stripeKey) : null
const PaymentWrapper: React.FC<PaymentWrapperProps> = ({ cart, children }) => {
const paymentSession = cart.payment_collection?.payment_sessions?.find(
(s) => s.status === "pending"
)
if (
isStripe(paymentSession?.provider_id) &&
paymentSession &&
stripePromise
) {
return (
<StripeWrapper
paymentSession={paymentSession}
stripeKey={stripeKey}
stripePromise={stripePromise}
>
{children}
</StripeWrapper>
)
}
return <div>{children}</div>
}
export default PaymentWrapper
@@ -0,0 +1,54 @@
"use client"
import { Stripe, StripeElementsOptions } from "@stripe/stripe-js"
import { Elements } from "@stripe/react-stripe-js"
import { HttpTypes } from "@medusajs/types"
import { createContext } from "react"
type StripeWrapperProps = {
paymentSession: HttpTypes.StorePaymentSession
stripeKey?: string
stripePromise: Promise<Stripe | null> | null
children: React.ReactNode
}
export const StripeContext = createContext(false)
const StripeWrapper: React.FC<StripeWrapperProps> = ({
paymentSession,
stripeKey,
stripePromise,
children,
}) => {
const options: StripeElementsOptions = {
clientSecret: paymentSession!.data?.client_secret as string | undefined,
}
if (!stripeKey) {
throw new Error(
"Stripe key is missing. Set NEXT_PUBLIC_STRIPE_KEY environment variable."
)
}
if (!stripePromise) {
throw new Error(
"Stripe promise is missing. Make sure you have provided a valid Stripe key."
)
}
if (!paymentSession?.data?.client_secret) {
throw new Error(
"Stripe client secret is missing. Cannot initialize Stripe."
)
}
return (
<StripeContext.Provider value={true}>
<Elements options={options} stripe={stripePromise}>
{children}
</Elements>
</StripeContext.Provider>
)
}
export default StripeWrapper
@@ -0,0 +1,261 @@
"use client"
import { RadioGroup } from "@headlessui/react"
import { isStripe as isStripeFunc, paymentInfoMap } from "@lib/constants"
import { initiatePaymentSession } from "@lib/data/cart"
import { CheckCircleSolid, CreditCard } from "@medusajs/icons"
import { Button, Container, Heading, Text, clx } from "@medusajs/ui"
import ErrorMessage from "@modules/checkout/components/error-message"
import PaymentContainer, {
StripeCardContainer,
} from "@modules/checkout/components/payment-container"
import Divider from "@modules/common/components/divider"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
const Payment = ({
cart,
availablePaymentMethods,
}: {
cart: any
availablePaymentMethods: any[]
}) => {
const activeSession = cart.payment_collection?.payment_sessions?.find(
(paymentSession: any) => paymentSession.status === "pending"
)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [cardBrand, setCardBrand] = useState<string | null>(null)
const [cardComplete, setCardComplete] = useState(false)
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(
activeSession?.provider_id ?? ""
)
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const isOpen = searchParams.get("step") === "payment"
const isStripe = isStripeFunc(selectedPaymentMethod)
const setPaymentMethod = async (method: string) => {
setError(null)
setSelectedPaymentMethod(method)
if (isStripeFunc(method)) {
await initiatePaymentSession(cart, {
provider_id: method,
})
}
}
const paidByGiftcard =
cart?.gift_cards && cart?.gift_cards?.length > 0 && cart?.total === 0
const paymentReady =
(activeSession && cart?.shipping_methods.length !== 0) || paidByGiftcard
const createQueryString = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams)
params.set(name, value)
return params.toString()
},
[searchParams]
)
const handleEdit = () => {
router.push(pathname + "?" + createQueryString("step", "payment"), {
scroll: false,
})
}
const handleSubmit = async () => {
setIsLoading(true)
try {
const shouldInputCard =
isStripeFunc(selectedPaymentMethod) && !activeSession
const checkActiveSession =
activeSession?.provider_id === selectedPaymentMethod
if (!checkActiveSession) {
await initiatePaymentSession(cart, {
provider_id: selectedPaymentMethod,
})
}
if (!shouldInputCard) {
return router.push(
pathname + "?" + createQueryString("step", "review"),
{
scroll: false,
}
)
}
} catch (err: any) {
setError(err.message)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
setError(null)
}, [isOpen])
return (
<div className="bg-white">
<div className="flex flex-row items-center justify-between mb-6">
<Heading
level="h2"
className={clx(
"flex flex-row text-3xl-regular gap-x-2 items-baseline",
{
"opacity-50 pointer-events-none select-none":
!isOpen && !paymentReady,
}
)}
>
Payment
{!isOpen && paymentReady && <CheckCircleSolid />}
</Heading>
{!isOpen && paymentReady && (
<Text>
<button
onClick={handleEdit}
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
data-testid="edit-payment-button"
>
Edit
</button>
</Text>
)}
</div>
<div>
<div className={isOpen ? "block" : "hidden"}>
{!paidByGiftcard && availablePaymentMethods?.length && (
<>
<RadioGroup
value={selectedPaymentMethod}
onChange={(value: string) => setPaymentMethod(value)}
>
{availablePaymentMethods.map((paymentMethod) => (
<div key={paymentMethod.id}>
{isStripeFunc(paymentMethod.id) ? (
<StripeCardContainer
paymentProviderId={paymentMethod.id}
selectedPaymentOptionId={selectedPaymentMethod}
paymentInfoMap={paymentInfoMap}
setCardBrand={setCardBrand}
setError={setError}
setCardComplete={setCardComplete}
/>
) : (
<PaymentContainer
paymentInfoMap={paymentInfoMap}
paymentProviderId={paymentMethod.id}
selectedPaymentOptionId={selectedPaymentMethod}
/>
)}
</div>
))}
</RadioGroup>
</>
)}
{paidByGiftcard && (
<div className="flex flex-col w-1/3">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Payment method
</Text>
<Text
className="txt-medium text-ui-fg-subtle"
data-testid="payment-method-summary"
>
Gift card
</Text>
</div>
)}
<ErrorMessage
error={error}
data-testid="payment-method-error-message"
/>
<Button
size="large"
className="mt-6"
onClick={handleSubmit}
isLoading={isLoading}
disabled={
(isStripe && !cardComplete) ||
(!selectedPaymentMethod && !paidByGiftcard)
}
data-testid="submit-payment-button"
>
{!activeSession && isStripeFunc(selectedPaymentMethod)
? " Enter card details"
: "Continue to review"}
</Button>
</div>
<div className={isOpen ? "hidden" : "block"}>
{cart && paymentReady && activeSession ? (
<div className="flex items-start gap-x-1 w-full">
<div className="flex flex-col w-1/3">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Payment method
</Text>
<Text
className="txt-medium text-ui-fg-subtle"
data-testid="payment-method-summary"
>
{paymentInfoMap[activeSession?.provider_id]?.title ||
activeSession?.provider_id}
</Text>
</div>
<div className="flex flex-col w-1/3">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Payment details
</Text>
<div
className="flex gap-2 txt-medium text-ui-fg-subtle items-center"
data-testid="payment-details-summary"
>
<Container className="flex items-center h-7 w-fit p-2 bg-ui-button-neutral-hover">
{paymentInfoMap[selectedPaymentMethod]?.icon || (
<CreditCard />
)}
</Container>
<Text>
{isStripeFunc(selectedPaymentMethod) && cardBrand
? cardBrand
: "Another step will appear"}
</Text>
</div>
</div>
</div>
) : paidByGiftcard ? (
<div className="flex flex-col w-1/3">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Payment method
</Text>
<Text
className="txt-medium text-ui-fg-subtle"
data-testid="payment-method-summary"
>
Gift card
</Text>
</div>
) : null}
</div>
</div>
<Divider className="mt-8" />
</div>
)
}
export default Payment
@@ -0,0 +1,55 @@
"use client"
import { Heading, Text, clx } from "@medusajs/ui"
import PaymentButton from "../payment-button"
import { useSearchParams } from "next/navigation"
const Review = ({ cart }: { cart: any }) => {
const searchParams = useSearchParams()
const isOpen = searchParams.get("step") === "review"
const paidByGiftcard =
cart?.gift_cards && cart?.gift_cards?.length > 0 && cart?.total === 0
const previousStepsCompleted =
cart.shipping_address &&
cart.shipping_methods.length > 0 &&
(cart.payment_collection || paidByGiftcard)
return (
<div className="bg-white">
<div className="flex flex-row items-center justify-between mb-6">
<Heading
level="h2"
className={clx(
"flex flex-row text-3xl-regular gap-x-2 items-baseline",
{
"opacity-50 pointer-events-none select-none": !isOpen,
}
)}
>
Review
</Heading>
</div>
{isOpen && previousStepsCompleted && (
<>
<div className="flex items-start gap-x-1 w-full mb-6">
<div className="w-full">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
By clicking the Place Order button, you confirm that you have
read, understand and accept our Terms of Use, Terms of Sale and
Returns Policy and acknowledge that you have read Medusa
Store&apos;s Privacy Policy.
</Text>
</div>
</div>
<PaymentButton cart={cart} data-testid="submit-order-button" />
</>
)}
</div>
)
}
export default Review
@@ -0,0 +1,219 @@
import { HttpTypes } from "@medusajs/types"
import { Container } from "@medusajs/ui"
import Checkbox from "@modules/common/components/checkbox"
import Input from "@modules/common/components/input"
import { mapKeys } from "lodash"
import React, { useEffect, useMemo, useState } from "react"
import AddressSelect from "../address-select"
import CountrySelect from "../country-select"
const ShippingAddress = ({
customer,
cart,
checked,
onChange,
}: {
customer: HttpTypes.StoreCustomer | null
cart: HttpTypes.StoreCart | null
checked: boolean
onChange: () => void
}) => {
const [formData, setFormData] = useState<Record<string, any>>({
"shipping_address.first_name": cart?.shipping_address?.first_name || "",
"shipping_address.last_name": cart?.shipping_address?.last_name || "",
"shipping_address.address_1": cart?.shipping_address?.address_1 || "",
"shipping_address.company": cart?.shipping_address?.company || "",
"shipping_address.postal_code": cart?.shipping_address?.postal_code || "",
"shipping_address.city": cart?.shipping_address?.city || "",
"shipping_address.country_code": cart?.shipping_address?.country_code || "",
"shipping_address.province": cart?.shipping_address?.province || "",
"shipping_address.phone": cart?.shipping_address?.phone || "",
email: cart?.email || "",
})
const countriesInRegion = useMemo(
() => cart?.region?.countries?.map((c) => c.iso_2),
[cart?.region]
)
// check if customer has saved addresses that are in the current region
const addressesInRegion = useMemo(
() =>
customer?.addresses.filter(
(a) => a.country_code && countriesInRegion?.includes(a.country_code)
),
[customer?.addresses, countriesInRegion]
)
const setFormAddress = (
address?: HttpTypes.StoreCartAddress,
email?: string
) => {
address &&
setFormData((prevState: Record<string, any>) => ({
...prevState,
"shipping_address.first_name": address?.first_name || "",
"shipping_address.last_name": address?.last_name || "",
"shipping_address.address_1": address?.address_1 || "",
"shipping_address.company": address?.company || "",
"shipping_address.postal_code": address?.postal_code || "",
"shipping_address.city": address?.city || "",
"shipping_address.country_code": address?.country_code || "",
"shipping_address.province": address?.province || "",
"shipping_address.phone": address?.phone || "",
}))
email &&
setFormData((prevState: Record<string, any>) => ({
...prevState,
email: email,
}))
}
useEffect(() => {
// Ensure cart is not null and has a shipping_address before setting form data
if (cart && cart.shipping_address) {
setFormAddress(cart?.shipping_address, cart?.email)
}
if (cart && !cart.email && customer?.email) {
setFormAddress(undefined, customer.email)
}
}, [cart]) // Add cart as a dependency
const handleChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLInputElement | HTMLSelectElement
>
) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
})
}
return (
<>
{customer && (addressesInRegion?.length || 0) > 0 && (
<Container className="mb-6 flex flex-col gap-y-4 p-5">
<p className="text-small-regular">
{`Hi ${customer.first_name}, do you want to use one of your saved addresses?`}
</p>
<AddressSelect
addresses={customer.addresses}
addressInput={
mapKeys(formData, (_, key) =>
key.replace("shipping_address.", "")
) as HttpTypes.StoreCartAddress
}
onSelect={setFormAddress}
/>
</Container>
)}
<div className="grid grid-cols-2 gap-4">
<Input
label="First name"
name="shipping_address.first_name"
autoComplete="given-name"
value={formData["shipping_address.first_name"]}
onChange={handleChange}
required
data-testid="shipping-first-name-input"
/>
<Input
label="Last name"
name="shipping_address.last_name"
autoComplete="family-name"
value={formData["shipping_address.last_name"]}
onChange={handleChange}
required
data-testid="shipping-last-name-input"
/>
<Input
label="Address"
name="shipping_address.address_1"
autoComplete="address-line1"
value={formData["shipping_address.address_1"]}
onChange={handleChange}
required
data-testid="shipping-address-input"
/>
<Input
label="Company"
name="shipping_address.company"
value={formData["shipping_address.company"]}
onChange={handleChange}
autoComplete="organization"
data-testid="shipping-company-input"
/>
<Input
label="Postal code"
name="shipping_address.postal_code"
autoComplete="postal-code"
value={formData["shipping_address.postal_code"]}
onChange={handleChange}
required
data-testid="shipping-postal-code-input"
/>
<Input
label="City"
name="shipping_address.city"
autoComplete="address-level2"
value={formData["shipping_address.city"]}
onChange={handleChange}
required
data-testid="shipping-city-input"
/>
<CountrySelect
name="shipping_address.country_code"
autoComplete="country"
region={cart?.region}
value={formData["shipping_address.country_code"]}
onChange={handleChange}
required
data-testid="shipping-country-select"
/>
<Input
label="State / Province"
name="shipping_address.province"
autoComplete="address-level1"
value={formData["shipping_address.province"]}
onChange={handleChange}
data-testid="shipping-province-input"
/>
</div>
<div className="my-8">
<Checkbox
label="Billing address same as shipping address"
name="same_as_billing"
checked={checked}
onChange={onChange}
data-testid="billing-address-checkbox"
/>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<Input
label="Email"
name="email"
type="email"
title="Enter a valid email address."
autoComplete="email"
value={formData.email}
onChange={handleChange}
required
data-testid="shipping-email-input"
/>
<Input
label="Phone"
name="shipping_address.phone"
autoComplete="tel"
value={formData["shipping_address.phone"]}
onChange={handleChange}
data-testid="shipping-phone-input"
/>
</div>
</>
)
}
export default ShippingAddress
@@ -0,0 +1,408 @@
"use client"
import { Radio, RadioGroup } from "@headlessui/react"
import { setShippingMethod } from "@lib/data/cart"
import { calculatePriceForShippingOption } from "@lib/data/fulfillment"
import { convertToLocale } from "@lib/util/money"
import { CheckCircleSolid, Loader } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Button, clx, Heading, Text } from "@medusajs/ui"
import ErrorMessage from "@modules/checkout/components/error-message"
import Divider from "@modules/common/components/divider"
import MedusaRadio from "@modules/common/components/radio"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
const PICKUP_OPTION_ON = "__PICKUP_ON"
const PICKUP_OPTION_OFF = "__PICKUP_OFF"
type ShippingProps = {
cart: HttpTypes.StoreCart
availableShippingMethods: HttpTypes.StoreCartShippingOption[] | null
}
function formatAddress(address: HttpTypes.StoreCartAddress) {
if (!address) {
return ""
}
let ret = ""
if (address.address_1) {
ret += ` ${address.address_1}`
}
if (address.address_2) {
ret += `, ${address.address_2}`
}
if (address.postal_code) {
ret += `, ${address.postal_code} ${address.city}`
}
if (address.country_code) {
ret += `, ${address.country_code.toUpperCase()}`
}
return ret
}
const Shipping: React.FC<ShippingProps> = ({
cart,
availableShippingMethods,
}) => {
const [isLoading, setIsLoading] = useState(false)
const [isLoadingPrices, setIsLoadingPrices] = useState(true)
const [showPickupOptions, setShowPickupOptions] =
useState<string>(PICKUP_OPTION_OFF)
const [calculatedPricesMap, setCalculatedPricesMap] = useState<
Record<string, number>
>({})
const [error, setError] = useState<string | null>(null)
const [shippingMethodId, setShippingMethodId] = useState<string | null>(
cart.shipping_methods?.at(-1)?.shipping_option_id || null
)
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const isOpen = searchParams.get("step") === "delivery"
const _shippingMethods = availableShippingMethods?.filter(
(sm) => sm.service_zone?.fulfillment_set?.type !== "pickup"
)
const _pickupMethods = availableShippingMethods?.filter(
(sm) => sm.service_zone?.fulfillment_set?.type === "pickup"
)
const hasPickupOptions = !!_pickupMethods?.length
useEffect(() => {
setIsLoadingPrices(true)
if (_shippingMethods?.length) {
const promises = _shippingMethods
.filter((sm) => sm.price_type === "calculated")
.map((sm) => calculatePriceForShippingOption(sm.id, cart.id))
if (promises.length) {
Promise.allSettled(promises).then((res) => {
const pricesMap: Record<string, number> = {}
res
.filter((r) => r.status === "fulfilled")
.forEach((p) => (pricesMap[p.value?.id || ""] = p.value?.amount!))
setCalculatedPricesMap(pricesMap)
setIsLoadingPrices(false)
})
}
}
if (_pickupMethods?.find((m) => m.id === shippingMethodId)) {
setShowPickupOptions(PICKUP_OPTION_ON)
}
}, [availableShippingMethods])
const handleEdit = () => {
router.push(pathname + "?step=delivery", { scroll: false })
}
const handleSubmit = () => {
router.push(pathname + "?step=payment", { scroll: false })
}
const handleSetShippingMethod = async (
id: string,
variant: "shipping" | "pickup"
) => {
setError(null)
if (variant === "pickup") {
setShowPickupOptions(PICKUP_OPTION_ON)
} else {
setShowPickupOptions(PICKUP_OPTION_OFF)
}
let currentId: string | null = null
setIsLoading(true)
setShippingMethodId((prev) => {
currentId = prev
return id
})
await setShippingMethod({ cartId: cart.id, shippingMethodId: id })
.catch((err) => {
setShippingMethodId(currentId)
setError(err.message)
})
.finally(() => {
setIsLoading(false)
})
}
useEffect(() => {
setError(null)
}, [isOpen])
return (
<div className="bg-white">
<div className="flex flex-row items-center justify-between mb-6">
<Heading
level="h2"
className={clx(
"flex flex-row text-3xl-regular gap-x-2 items-baseline",
{
"opacity-50 pointer-events-none select-none":
!isOpen && cart.shipping_methods?.length === 0,
}
)}
>
Delivery
{!isOpen && (cart.shipping_methods?.length ?? 0) > 0 && (
<CheckCircleSolid />
)}
</Heading>
{!isOpen &&
cart?.shipping_address &&
cart?.billing_address &&
cart?.email && (
<Text>
<button
onClick={handleEdit}
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
data-testid="edit-delivery-button"
>
Edit
</button>
</Text>
)}
</div>
{isOpen ? (
<>
<div className="grid">
<div className="flex flex-col">
<span className="font-medium txt-medium text-ui-fg-base">
Shipping method
</span>
<span className="mb-4 text-ui-fg-muted txt-medium">
How would you like you order delivered
</span>
</div>
<div data-testid="delivery-options-container">
<div className="pb-8 md:pt-0 pt-2">
{hasPickupOptions && (
<RadioGroup
value={showPickupOptions}
onChange={(value) => {
const id = _pickupMethods.find(
(option) => !option.insufficient_inventory
)?.id
if (id) {
handleSetShippingMethod(id, "pickup")
}
}}
>
<Radio
value={PICKUP_OPTION_ON}
data-testid="delivery-option-radio"
className={clx(
"flex items-center justify-between text-small-regular cursor-pointer py-4 border rounded-rounded px-8 mb-2 hover:shadow-borders-interactive-with-active",
{
"border-ui-border-interactive":
showPickupOptions === PICKUP_OPTION_ON,
}
)}
>
<div className="flex items-center gap-x-4">
<MedusaRadio
checked={showPickupOptions === PICKUP_OPTION_ON}
/>
<span className="text-base-regular">
Pick up your order
</span>
</div>
<span className="justify-self-end text-ui-fg-base">
-
</span>
</Radio>
</RadioGroup>
)}
<RadioGroup
value={shippingMethodId}
onChange={(v) => {
if (v) {
return handleSetShippingMethod(v, "shipping")
}
}}
>
{_shippingMethods?.map((option) => {
const isDisabled =
option.price_type === "calculated" &&
!isLoadingPrices &&
typeof calculatedPricesMap[option.id] !== "number"
return (
<Radio
key={option.id}
value={option.id}
data-testid="delivery-option-radio"
disabled={isDisabled}
className={clx(
"flex items-center justify-between text-small-regular cursor-pointer py-4 border rounded-rounded px-8 mb-2 hover:shadow-borders-interactive-with-active",
{
"border-ui-border-interactive":
option.id === shippingMethodId,
"hover:shadow-brders-none cursor-not-allowed":
isDisabled,
}
)}
>
<div className="flex items-center gap-x-4">
<MedusaRadio
checked={option.id === shippingMethodId}
/>
<span className="text-base-regular">
{option.name}
</span>
</div>
<span className="justify-self-end text-ui-fg-base">
{option.price_type === "flat" ? (
convertToLocale({
amount: option.amount!,
currency_code: cart?.currency_code,
})
) : calculatedPricesMap[option.id] ? (
convertToLocale({
amount: calculatedPricesMap[option.id],
currency_code: cart?.currency_code,
})
) : isLoadingPrices ? (
<Loader />
) : (
"-"
)}
</span>
</Radio>
)
})}
</RadioGroup>
</div>
</div>
</div>
{showPickupOptions === PICKUP_OPTION_ON && (
<div className="grid">
<div className="flex flex-col">
<span className="font-medium txt-medium text-ui-fg-base">
Store
</span>
<span className="mb-4 text-ui-fg-muted txt-medium">
Choose a store near you
</span>
</div>
<div data-testid="delivery-options-container">
<div className="pb-8 md:pt-0 pt-2">
<RadioGroup
value={shippingMethodId}
onChange={(v) => {
if (v) {
return handleSetShippingMethod(v, "pickup")
}
}}
>
{_pickupMethods?.map((option) => {
return (
<Radio
key={option.id}
value={option.id}
disabled={option.insufficient_inventory}
data-testid="delivery-option-radio"
className={clx(
"flex items-center justify-between text-small-regular cursor-pointer py-4 border rounded-rounded px-8 mb-2 hover:shadow-borders-interactive-with-active",
{
"border-ui-border-interactive":
option.id === shippingMethodId,
"hover:shadow-brders-none cursor-not-allowed":
option.insufficient_inventory,
}
)}
>
<div className="flex items-start gap-x-4">
<MedusaRadio
checked={option.id === shippingMethodId}
/>
<div className="flex flex-col">
<span className="text-base-regular">
{option.name}
</span>
<span className="text-base-regular text-ui-fg-muted">
{formatAddress(
option.service_zone?.fulfillment_set?.location
?.address
)}
</span>
</div>
</div>
<span className="justify-self-end text-ui-fg-base">
{convertToLocale({
amount: option.amount!,
currency_code: cart?.currency_code,
})}
</span>
</Radio>
)
})}
</RadioGroup>
</div>
</div>
</div>
)}
<div>
<ErrorMessage
error={error}
data-testid="delivery-option-error-message"
/>
<Button
size="large"
className="mt"
onClick={handleSubmit}
isLoading={isLoading}
disabled={!cart.shipping_methods?.[0]}
data-testid="submit-delivery-option-button"
>
Continue to payment
</Button>
</div>
</>
) : (
<div>
<div className="text-small-regular">
{cart && (cart.shipping_methods?.length ?? 0) > 0 && (
<div className="flex flex-col w-1/3">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Method
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.shipping_methods!.at(-1)!.name}{" "}
{convertToLocale({
amount: cart.shipping_methods!.at(-1)!.amount!,
currency_code: cart?.currency_code,
})}
</Text>
</div>
)}
</div>
</div>
)}
<Divider className="mt-8" />
</div>
)
}
export default Shipping

Some files were not shown because too many files have changed in this diff Show More