move selfservice tables to medreport schema

add base medusa store frontend
This commit is contained in:
Danel Kungla
2025-07-07 13:46:22 +03:00
parent 297dd7c221
commit 2e62e4b0eb
237 changed files with 33991 additions and 189 deletions

View File

@@ -45,8 +45,7 @@
"react-dom": "19.1.0",
"react-hook-form": "^7.56.3",
"react-i18next": "^15.5.1",
"sonner": "^2.0.3",
"zod": "^3.24.4"
"sonner": "^2.0.3"
},
"prettier": "@kit/prettier-config",
"typesVersions": {

View File

@@ -28,8 +28,7 @@
"next": "15.3.2",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.56.3",
"zod": "^3.24.4"
"react-hook-form": "^7.56.3"
},
"exports": {
".": "./src/index.ts",

View File

@@ -35,8 +35,7 @@
"next": "15.3.2",
"react-hook-form": "^7.56.3",
"react-i18next": "^15.5.1",
"sonner": "^2.0.3",
"zod": "^3.24.4"
"sonner": "^2.0.3"
},
"prettier": "@kit/prettier-config",
"typesVersions": {

View File

@@ -0,0 +1,125 @@
<p align="center">
<a href="https://www.medusajs.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/59018053/229103275-b5e482bb-4601-46e6-8142-244f531cebdb.svg">
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
<img alt="Medusa logo" src="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
</picture>
</a>
</p>
<h1 align="center">
Medusa Next.js Starter Template
</h1>
<p align="center">
Combine Medusa's modules for your commerce backend with the newest Next.js 15 features for a performant storefront.</p>
<p align="center">
<a href="https://github.com/medusajs/medusa/blob/master/CONTRIBUTING.md">
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs welcome!" />
</a>
<a href="https://discord.gg/xpCwq3Kfn8">
<img src="https://img.shields.io/badge/chat-on%20discord-7289DA.svg" alt="Discord Chat" />
</a>
<a href="https://twitter.com/intent/follow?screen_name=medusajs">
<img src="https://img.shields.io/twitter/follow/medusajs.svg?label=Follow%20@medusajs" alt="Follow @medusajs" />
</a>
</p>
### Prerequisites
To use the [Next.js Starter Template](https://medusajs.com/nextjs-commerce/), you should have a Medusa server running locally on port 9000.
For a quick setup, run:
```shell
npx create-medusa-app@latest
```
Check out [create-medusa-app docs](https://docs.medusajs.com/learn/installation) for more details and troubleshooting.
# Overview
The Medusa Next.js Starter is built with:
- [Next.js](https://nextjs.org/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Typescript](https://www.typescriptlang.org/)
- [Medusa](https://medusajs.com/)
Features include:
- Full ecommerce support:
- Product Detail Page
- Product Overview Page
- Product Collections
- Cart
- Checkout with Stripe
- User Accounts
- Order Details
- Full Next.js 15 support:
- App Router
- Next fetching/caching
- Server Components
- Server Actions
- Streaming
- Static Pre-Rendering
# Quickstart
### Setting up the environment variables
Navigate into your projects directory and get your environment variables ready:
```shell
cd nextjs-starter-medusa/
mv .env.template .env.local
```
### Install dependencies
Use Yarn to install all dependencies.
```shell
yarn
```
### Start developing
You are now ready to start up your project.
```shell
yarn dev
```
### Open the code and start customizing
Your site is now running at http://localhost:8000!
# Payment integrations
By default this starter supports the following payment integrations
- [Stripe](https://stripe.com/)
To enable the integrations you need to add the following to your `.env.local` file:
```shell
NEXT_PUBLIC_STRIPE_KEY=<your-stripe-public-key>
```
You'll also need to setup the integrations in your Medusa server. See the [Medusa documentation](https://docs.medusajs.com) for more information on how to configure [Stripe](https://docs.medusajs.com/resources/commerce-modules/payment/payment-provider/stripe#main).
# Resources
## Learn more about Medusa
- [Website](https://www.medusajs.com/)
- [GitHub](https://github.com/medusajs)
- [Documentation](https://docs.medusajs.com/)
## Learn more about Next.js
- [Website](https://nextjs.org/)
- [GitHub](https://github.com/vercel/next.js)
- [Documentation](https://nextjs.org/docs)

View File

@@ -0,0 +1,39 @@
const c = require("ansi-colors")
const requiredEnvs = [
{
key: "NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY",
// TODO: we need a good doc to point this to
description:
"Learn how to create a publishable key: https://docs.medusajs.com/v2/resources/storefront-development/publishable-api-keys",
},
]
function checkEnvVariables() {
const missingEnvs = requiredEnvs.filter(function (env) {
return !process.env[env.key]
})
if (missingEnvs.length > 0) {
console.error(
c.red.bold("\n🚫 Error: Missing required environment variables\n")
)
missingEnvs.forEach(function (env) {
console.error(c.yellow(` ${c.bold(env.key)}`))
if (env.description) {
console.error(c.dim(` ${env.description}\n`))
}
})
console.error(
c.yellow(
"\nPlease set these variables in your .env file or environment before starting the application.\n"
)
)
process.exit(1)
}
}
module.exports = checkEnvVariables

View File

@@ -0,0 +1,60 @@
{
"name": "medusa-next",
"version": "1.0.3",
"private": true,
"author": "Kasper Fabricius Kristensen <kasper@medusajs.com> & Victor Gerbrands <victor@medusajs.com> (https://www.medusajs.com)",
"description": "Next.js Starter to be used with Medusa V2",
"keywords": [
"medusa-storefront"
],
"scripts": {
"dev": "next dev --turbopack -p 8000",
"build": "next build",
"start": "next start -p 8000",
"lint": "next lint",
"analyze": "ANALYZE=true next build"
},
"dependencies": {
"@headlessui/react": "^2.2.0",
"@medusajs/js-sdk": "latest",
"@medusajs/ui": "latest",
"@radix-ui/react-accordion": "^1.2.1",
"@stripe/react-stripe-js": "^1.7.2",
"@stripe/stripe-js": "^1.29.0",
"lodash": "^4.17.21",
"next": "^15.3.1",
"pg": "^8.11.3",
"qs": "^6.12.1",
"react": "19.0.0-rc-66855b96-20241106",
"react-country-flag": "^3.1.0",
"react-dom": "19.0.0-rc-66855b96-20241106",
"server-only": "^0.0.1",
"tailwindcss-radix": "^2.8.0",
"webpack": "^5"
},
"devDependencies": {
"@babel/core": "^7.17.5",
"@medusajs/types": "latest",
"@medusajs/ui-preset": "latest",
"@types/lodash": "^4.14.195",
"@types/node": "17.0.21",
"@types/pg": "^8.11.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-instantsearch-dom": "^6.12.3",
"ansi-colors": "^4.1.3",
"autoprefixer": "^10.4.2",
"babel-loader": "^8.2.3",
"eslint": "8.10.0",
"eslint-config-next": "15.0.3",
"postcss": "^8.4.8",
"prettier": "^2.8.8",
"tailwindcss": "^3.0.23",
"typescript": "^5.3.2"
},
"packageManager": "yarn@3.2.3",
"overrides": {
"react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106"
}
}

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

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",
]

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
}

View File

@@ -0,0 +1,472 @@
"use server";
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";
import { sdk } from "@lib/config";
/**
* 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) {
const id = cartId || (await getCartId());
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:
"*items, *region, *items.product, *items.variant, *items.thumbnail, *items.metadata, +items.total, *promotions, +shipping_methods.name",
},
headers,
next,
cache: "force-cache",
})
.then(({ cart }) => 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();
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 }) => {
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",
});
}

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

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

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

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

View File

@@ -0,0 +1,70 @@
"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,
fields:
"+service_zone.fulfllment_set.type,*service_zone.fulfillment_set.location.address",
},
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
})
}

View File

@@ -0,0 +1,11 @@
export * from "./cart";
export * from "./categories";
export * from "./collections";
export * from "./cookies";
export * from "./customer";
export * from "./fulfillment";
export * from "./onboarding";
export * from "./orders";
export * from "./payment";
export * from "./products";
export * from "./regions";

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

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

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

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.StoreProductParams
countryCode?: string
regionId?: string
}): Promise<{
response: { products: HttpTypes.StoreProduct[]; count: number }
nextPage: number | null
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
}> => {
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,
}
}

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

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
}

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

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",
])
)
}

View File

@@ -0,0 +1,3 @@
export const getBaseURL = () => {
return process.env.NEXT_PUBLIC_BASE_URL || "https://localhost:8000"
}

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

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(),
}
}

View File

@@ -0,0 +1,10 @@
export * from "./compare-addresses";
export * from "./env";
export * from "./get-precentage-diff";
export * from "./get-product-price";
export * from "./isEmpty";
export * from "./medusa-error";
export * from "./money";
export * from "./product";
export * from "./repeat";
export * from "./sort-products";

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

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

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

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;
}

View File

@@ -0,0 +1,5 @@
const repeat = (times: number) => {
return Array.from(Array(times).keys())
}
export default repeat

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
}

View File

@@ -0,0 +1,160 @@
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
) {
console.log("PUBLISHABLE_API_KEY", PUBLISHABLE_API_KEY)
// 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)
}
return response
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|images|assets|png|svg|jpg|jpeg|gif|webp).*)",
],
}

View File

@@ -0,0 +1,139 @@
import { Disclosure } from "@headlessui/react"
import { Badge, Button, clx } from "@medusajs/ui"
import { useEffect } from "react"
import useToggleState from "@lib/hooks/use-toggle-state"
import { useFormStatus } from "react-dom"
type AccountInfoProps = {
label: string
currentInfo: string | React.ReactNode
isSuccess?: boolean
isError?: boolean
errorMessage?: string
clearState: () => void
children?: React.ReactNode
'data-testid'?: string
}
const AccountInfo = ({
label,
currentInfo,
isSuccess,
isError,
clearState,
errorMessage = "An error occurred, please try again",
children,
'data-testid': dataTestid
}: AccountInfoProps) => {
const { state, close, toggle } = useToggleState()
const { pending } = useFormStatus()
const handleToggle = () => {
clearState()
setTimeout(() => toggle(), 100)
}
useEffect(() => {
if (isSuccess) {
close()
}
}, [isSuccess, close])
return (
<div className="text-small-regular" data-testid={dataTestid}>
<div className="flex items-end justify-between">
<div className="flex flex-col">
<span className="uppercase text-ui-fg-base">{label}</span>
<div className="flex items-center flex-1 basis-0 justify-end gap-x-4">
{typeof currentInfo === "string" ? (
<span className="font-semibold" data-testid="current-info">{currentInfo}</span>
) : (
currentInfo
)}
</div>
</div>
<div>
<Button
variant="secondary"
className="w-[100px] min-h-[25px] py-1"
onClick={handleToggle}
type={state ? "reset" : "button"}
data-testid="edit-button"
data-active={state}
>
{state ? "Cancel" : "Edit"}
</Button>
</div>
</div>
{/* Success state */}
<Disclosure>
<Disclosure.Panel
static
className={clx(
"transition-[max-height,opacity] duration-300 ease-in-out overflow-hidden",
{
"max-h-[1000px] opacity-100": isSuccess,
"max-h-0 opacity-0": !isSuccess,
}
)}
data-testid="success-message"
>
<Badge className="p-2 my-4" color="green">
<span>{label} updated succesfully</span>
</Badge>
</Disclosure.Panel>
</Disclosure>
{/* Error state */}
<Disclosure>
<Disclosure.Panel
static
className={clx(
"transition-[max-height,opacity] duration-300 ease-in-out overflow-hidden",
{
"max-h-[1000px] opacity-100": isError,
"max-h-0 opacity-0": !isError,
}
)}
data-testid="error-message"
>
<Badge className="p-2 my-4" color="red">
<span>{errorMessage}</span>
</Badge>
</Disclosure.Panel>
</Disclosure>
<Disclosure>
<Disclosure.Panel
static
className={clx(
"transition-[max-height,opacity] duration-300 ease-in-out overflow-visible",
{
"max-h-[1000px] opacity-100": state,
"max-h-0 opacity-0": !state,
}
)}
>
<div className="flex flex-col gap-y-2 py-4">
<div>{children}</div>
<div className="flex items-center justify-end mt-2">
<Button
isLoading={pending}
className="w-full small:max-w-[140px]"
type="submit"
data-testid="save-button"
>
Save changes
</Button>
</div>
</div>
</Disclosure.Panel>
</Disclosure>
</div>
)
}
export default AccountInfo

View File

@@ -0,0 +1,199 @@
"use client"
import { clx } from "@medusajs/ui"
import { ArrowRightOnRectangle } from "@medusajs/icons"
import { useParams, usePathname } from "next/navigation"
import ChevronDown from "@modules/common/icons/chevron-down"
import User from "@modules/common/icons/user"
import MapPin from "@modules/common/icons/map-pin"
import Package from "@modules/common/icons/package"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { HttpTypes } from "@medusajs/types"
import { signout } from "@lib/data/customer"
const AccountNav = ({
customer,
}: {
customer: HttpTypes.StoreCustomer | null
}) => {
const route = usePathname()
const { countryCode } = useParams() as { countryCode: string }
const handleLogout = async () => {
await signout(countryCode)
}
return (
<div>
<div className="small:hidden" data-testid="mobile-account-nav">
{route !== `/${countryCode}/account` ? (
<LocalizedClientLink
href="/account"
className="flex items-center gap-x-2 text-small-regular py-2"
data-testid="account-main-link"
>
<>
<ChevronDown className="transform rotate-90" />
<span>Account</span>
</>
</LocalizedClientLink>
) : (
<>
<div className="text-xl-semi mb-4 px-8">
Hello {customer?.first_name}
</div>
<div className="text-base-regular">
<ul>
<li>
<LocalizedClientLink
href="/account/profile"
className="flex items-center justify-between py-4 border-b border-gray-200 px-8"
data-testid="profile-link"
>
<>
<div className="flex items-center gap-x-2">
<User size={20} />
<span>Profile</span>
</div>
<ChevronDown className="transform -rotate-90" />
</>
</LocalizedClientLink>
</li>
<li>
<LocalizedClientLink
href="/account/addresses"
className="flex items-center justify-between py-4 border-b border-gray-200 px-8"
data-testid="addresses-link"
>
<>
<div className="flex items-center gap-x-2">
<MapPin size={20} />
<span>Addresses</span>
</div>
<ChevronDown className="transform -rotate-90" />
</>
</LocalizedClientLink>
</li>
<li>
<LocalizedClientLink
href="/account/orders"
className="flex items-center justify-between py-4 border-b border-gray-200 px-8"
data-testid="orders-link"
>
<div className="flex items-center gap-x-2">
<Package size={20} />
<span>Orders</span>
</div>
<ChevronDown className="transform -rotate-90" />
</LocalizedClientLink>
</li>
<li>
<button
type="button"
className="flex items-center justify-between py-4 border-b border-gray-200 px-8 w-full"
onClick={handleLogout}
data-testid="logout-button"
>
<div className="flex items-center gap-x-2">
<ArrowRightOnRectangle />
<span>Log out</span>
</div>
<ChevronDown className="transform -rotate-90" />
</button>
</li>
</ul>
</div>
</>
)}
</div>
<div className="hidden small:block" data-testid="account-nav">
<div>
<div className="pb-4">
<h3 className="text-base-semi">Account</h3>
</div>
<div className="text-base-regular">
<ul className="flex mb-0 justify-start items-start flex-col gap-y-4">
<li>
<AccountNavLink
href="/account"
route={route!}
data-testid="overview-link"
>
Overview
</AccountNavLink>
</li>
<li>
<AccountNavLink
href="/account/profile"
route={route!}
data-testid="profile-link"
>
Profile
</AccountNavLink>
</li>
<li>
<AccountNavLink
href="/account/addresses"
route={route!}
data-testid="addresses-link"
>
Addresses
</AccountNavLink>
</li>
<li>
<AccountNavLink
href="/account/orders"
route={route!}
data-testid="orders-link"
>
Orders
</AccountNavLink>
</li>
<li className="text-grey-700">
<button
type="button"
onClick={handleLogout}
data-testid="logout-button"
>
Log out
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
)
}
type AccountNavLinkProps = {
href: string
route: string
children: React.ReactNode
"data-testid"?: string
}
const AccountNavLink = ({
href,
route,
children,
"data-testid": dataTestId,
}: AccountNavLinkProps) => {
const { countryCode }: { countryCode: string } = useParams()
const active = route.split(countryCode)[1] === href
return (
<LocalizedClientLink
href={href}
className={clx("text-ui-fg-subtle hover:text-ui-fg-base", {
"text-ui-fg-base font-semibold": active,
})}
data-testid={dataTestId}
>
{children}
</LocalizedClientLink>
)
}
export default AccountNav

View File

@@ -0,0 +1,28 @@
import React from "react"
import AddAddress from "../address-card/add-address"
import EditAddress from "../address-card/edit-address-modal"
import { HttpTypes } from "@medusajs/types"
type AddressBookProps = {
customer: HttpTypes.StoreCustomer
region: HttpTypes.StoreRegion
}
const AddressBook: React.FC<AddressBookProps> = ({ customer, region }) => {
const { addresses } = customer
return (
<div className="w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1 mt-4">
<AddAddress region={region} addresses={addresses} />
{addresses.map((address) => {
return (
<EditAddress region={region} address={address} key={address.id} />
)
})}
</div>
</div>
)
}
export default AddressBook

View File

@@ -0,0 +1,167 @@
"use client"
import { Plus } from "@medusajs/icons"
import { Button, Heading } from "@medusajs/ui"
import { useEffect, useState, useActionState } from "react"
import useToggleState from "@lib/hooks/use-toggle-state"
import CountrySelect from "@modules/checkout/components/country-select"
import Input from "@modules/common/components/input"
import Modal from "@modules/common/components/modal"
import { SubmitButton } from "@modules/checkout/components/submit-button"
import { HttpTypes } from "@medusajs/types"
import { addCustomerAddress } from "@lib/data/customer"
const AddAddress = ({
region,
addresses,
}: {
region: HttpTypes.StoreRegion
addresses: HttpTypes.StoreCustomerAddress[]
}) => {
const [successState, setSuccessState] = useState(false)
const { state, open, close: closeModal } = useToggleState(false)
const [formState, formAction] = useActionState(addCustomerAddress, {
isDefaultShipping: addresses.length === 0,
success: false,
error: null,
})
const close = () => {
setSuccessState(false)
closeModal()
}
useEffect(() => {
if (successState) {
close()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [successState])
useEffect(() => {
if (formState.success) {
setSuccessState(true)
}
}, [formState])
return (
<>
<button
className="border border-ui-border-base rounded-rounded p-5 min-h-[220px] h-full w-full flex flex-col justify-between"
onClick={open}
data-testid="add-address-button"
>
<span className="text-base-semi">New address</span>
<Plus />
</button>
<Modal isOpen={state} close={close} data-testid="add-address-modal">
<Modal.Title>
<Heading className="mb-2">Add address</Heading>
</Modal.Title>
<form action={formAction}>
<Modal.Body>
<div className="flex flex-col gap-y-2">
<div className="grid grid-cols-2 gap-x-2">
<Input
label="First name"
name="first_name"
required
autoComplete="given-name"
data-testid="first-name-input"
/>
<Input
label="Last name"
name="last_name"
required
autoComplete="family-name"
data-testid="last-name-input"
/>
</div>
<Input
label="Company"
name="company"
autoComplete="organization"
data-testid="company-input"
/>
<Input
label="Address"
name="address_1"
required
autoComplete="address-line1"
data-testid="address-1-input"
/>
<Input
label="Apartment, suite, etc."
name="address_2"
autoComplete="address-line2"
data-testid="address-2-input"
/>
<div className="grid grid-cols-[144px_1fr] gap-x-2">
<Input
label="Postal code"
name="postal_code"
required
autoComplete="postal-code"
data-testid="postal-code-input"
/>
<Input
label="City"
name="city"
required
autoComplete="locality"
data-testid="city-input"
/>
</div>
<Input
label="Province / State"
name="province"
autoComplete="address-level1"
data-testid="state-input"
/>
<CountrySelect
region={region}
name="country_code"
required
autoComplete="country"
data-testid="country-select"
/>
<Input
label="Phone"
name="phone"
autoComplete="phone"
data-testid="phone-input"
/>
</div>
{formState.error && (
<div
className="text-rose-500 text-small-regular py-2"
data-testid="address-error"
>
{formState.error}
</div>
)}
</Modal.Body>
<Modal.Footer>
<div className="flex gap-3 mt-6">
<Button
type="reset"
variant="secondary"
onClick={close}
className="h-10"
data-testid="cancel-button"
>
Cancel
</Button>
<SubmitButton data-testid="save-button">Save</SubmitButton>
</div>
</Modal.Footer>
</form>
</Modal>
</>
)
}
export default AddAddress

View File

@@ -0,0 +1,239 @@
"use client"
import React, { useEffect, useState, useActionState } from "react"
import { PencilSquare as Edit, Trash } from "@medusajs/icons"
import { Button, Heading, Text, clx } from "@medusajs/ui"
import useToggleState from "@lib/hooks/use-toggle-state"
import CountrySelect from "@modules/checkout/components/country-select"
import Input from "@modules/common/components/input"
import Modal from "@modules/common/components/modal"
import Spinner from "@modules/common/icons/spinner"
import { SubmitButton } from "@modules/checkout/components/submit-button"
import { HttpTypes } from "@medusajs/types"
import {
deleteCustomerAddress,
updateCustomerAddress,
} from "@lib/data/customer"
type EditAddressProps = {
region: HttpTypes.StoreRegion
address: HttpTypes.StoreCustomerAddress
isActive?: boolean
}
const EditAddress: React.FC<EditAddressProps> = ({
region,
address,
isActive = false,
}) => {
const [removing, setRemoving] = useState(false)
const [successState, setSuccessState] = useState(false)
const { state, open, close: closeModal } = useToggleState(false)
const [formState, formAction] = useActionState(updateCustomerAddress, {
success: false,
error: null,
addressId: address.id,
})
const close = () => {
setSuccessState(false)
closeModal()
}
useEffect(() => {
if (successState) {
close()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [successState])
useEffect(() => {
if (formState.success) {
setSuccessState(true)
}
}, [formState])
const removeAddress = async () => {
setRemoving(true)
await deleteCustomerAddress(address.id)
setRemoving(false)
}
return (
<>
<div
className={clx(
"border rounded-rounded p-5 min-h-[220px] h-full w-full flex flex-col justify-between transition-colors",
{
"border-gray-900": isActive,
}
)}
data-testid="address-container"
>
<div className="flex flex-col">
<Heading
className="text-left text-base-semi"
data-testid="address-name"
>
{address.first_name} {address.last_name}
</Heading>
{address.company && (
<Text
className="txt-compact-small text-ui-fg-base"
data-testid="address-company"
>
{address.company}
</Text>
)}
<Text className="flex flex-col text-left text-base-regular mt-2">
<span data-testid="address-address">
{address.address_1}
{address.address_2 && <span>, {address.address_2}</span>}
</span>
<span data-testid="address-postal-city">
{address.postal_code}, {address.city}
</span>
<span data-testid="address-province-country">
{address.province && `${address.province}, `}
{address.country_code?.toUpperCase()}
</span>
</Text>
</div>
<div className="flex items-center gap-x-4">
<button
className="text-small-regular text-ui-fg-base flex items-center gap-x-2"
onClick={open}
data-testid="address-edit-button"
>
<Edit />
Edit
</button>
<button
className="text-small-regular text-ui-fg-base flex items-center gap-x-2"
onClick={removeAddress}
data-testid="address-delete-button"
>
{removing ? <Spinner /> : <Trash />}
Remove
</button>
</div>
</div>
<Modal isOpen={state} close={close} data-testid="edit-address-modal">
<Modal.Title>
<Heading className="mb-2">Edit address</Heading>
</Modal.Title>
<form action={formAction}>
<input type="hidden" name="addressId" value={address.id} />
<Modal.Body>
<div className="grid grid-cols-1 gap-y-2">
<div className="grid grid-cols-2 gap-x-2">
<Input
label="First name"
name="first_name"
required
autoComplete="given-name"
defaultValue={address.first_name || undefined}
data-testid="first-name-input"
/>
<Input
label="Last name"
name="last_name"
required
autoComplete="family-name"
defaultValue={address.last_name || undefined}
data-testid="last-name-input"
/>
</div>
<Input
label="Company"
name="company"
autoComplete="organization"
defaultValue={address.company || undefined}
data-testid="company-input"
/>
<Input
label="Address"
name="address_1"
required
autoComplete="address-line1"
defaultValue={address.address_1 || undefined}
data-testid="address-1-input"
/>
<Input
label="Apartment, suite, etc."
name="address_2"
autoComplete="address-line2"
defaultValue={address.address_2 || undefined}
data-testid="address-2-input"
/>
<div className="grid grid-cols-[144px_1fr] gap-x-2">
<Input
label="Postal code"
name="postal_code"
required
autoComplete="postal-code"
defaultValue={address.postal_code || undefined}
data-testid="postal-code-input"
/>
<Input
label="City"
name="city"
required
autoComplete="locality"
defaultValue={address.city || undefined}
data-testid="city-input"
/>
</div>
<Input
label="Province / State"
name="province"
autoComplete="address-level1"
defaultValue={address.province || undefined}
data-testid="state-input"
/>
<CountrySelect
name="country_code"
region={region}
required
autoComplete="country"
defaultValue={address.country_code || undefined}
data-testid="country-select"
/>
<Input
label="Phone"
name="phone"
autoComplete="phone"
defaultValue={address.phone || undefined}
data-testid="phone-input"
/>
</div>
{formState.error && (
<div className="text-rose-500 text-small-regular py-2">
{formState.error}
</div>
)}
</Modal.Body>
<Modal.Footer>
<div className="flex gap-3 mt-6">
<Button
type="reset"
variant="secondary"
onClick={close}
className="h-10"
data-testid="cancel-button"
>
Cancel
</Button>
<SubmitButton data-testid="save-button">Save</SubmitButton>
</div>
</Modal.Footer>
</form>
</Modal>
</>
)
}
export default EditAddress

View File

@@ -0,0 +1,55 @@
// account-info
export { default as AccountInfo } from "./account-info";
export * from "./account-info";
// account-nav
export { default as AccountNav } from "./account-nav";
export * from "./account-nav";
// address-book
export { default as AddressBook } from "./address-book";
export * from "./address-book";
// login
export { default as Login } from "./login";
export * from "./login";
// order-card
export { default as OrderCard } from "./order-card";
export * from "./order-card";
// order-overview
export { default as OrderOverview } from "./order-overview";
export * from "./order-overview";
// overview
export { default as Overview } from "./overview";
export * from "./overview";
// profile-billing-address
export { default as ProfileBillingAddress } from "./profile-billing-address";
export * from "./profile-billing-address";
// profile-email
export { default as ProfileEmail } from "./profile-email";
export * from "./profile-email";
// profile-name
export { default as ProfileName } from "./profile-name";
export * from "./profile-name";
// profile-password
export { default as ProfilePassword } from "./profile-password";
export * from "./profile-password";
// profile-phone
export { default as ProfilePhone } from "./profile-phone";
export * from "./profile-phone";
// register
export { default as Register } from "./register";
export * from "./register";
// transfer-request-form
export { default as TransferRequestForm } from "./transfer-request-form";
export * from "./transfer-request-form";

View File

@@ -0,0 +1,64 @@
import { login } from "@lib/data/customer"
import { LOGIN_VIEW } from "@modules/account/templates/login-template"
import ErrorMessage from "@modules/checkout/components/error-message"
import { SubmitButton } from "@modules/checkout/components/submit-button"
import Input from "@modules/common/components/input"
import { useActionState } from "react"
type Props = {
setCurrentView: (view: LOGIN_VIEW) => void
}
const Login = ({ setCurrentView }: Props) => {
const [message, formAction] = useActionState(login, null)
return (
<div
className="max-w-sm w-full flex flex-col items-center"
data-testid="login-page"
>
<h1 className="text-large-semi uppercase mb-6">Welcome back</h1>
<p className="text-center text-base-regular text-ui-fg-base mb-8">
Sign in to access an enhanced shopping experience.
</p>
<form className="w-full" action={formAction}>
<div className="flex flex-col w-full gap-y-2">
<Input
label="Email"
name="email"
type="email"
title="Enter a valid email address."
autoComplete="email"
required
data-testid="email-input"
/>
<Input
label="Password"
name="password"
type="password"
autoComplete="current-password"
required
data-testid="password-input"
/>
</div>
<ErrorMessage error={message} data-testid="login-error-message" />
<SubmitButton data-testid="sign-in-button" className="w-full mt-6">
Sign in
</SubmitButton>
</form>
<span className="text-center text-ui-fg-base text-small-regular mt-6">
Not a member?{" "}
<button
onClick={() => setCurrentView(LOGIN_VIEW.REGISTER)}
className="underline"
data-testid="register-button"
>
Join us
</button>
.
</span>
</div>
)
}
export default Login

View File

@@ -0,0 +1,87 @@
import { Button } from "@medusajs/ui"
import { useMemo } from "react"
import Thumbnail from "@modules/products/components/thumbnail"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { convertToLocale } from "@lib/util/money"
import { HttpTypes } from "@medusajs/types"
type OrderCardProps = {
order: HttpTypes.StoreOrder
}
const OrderCard = ({ order }: OrderCardProps) => {
const numberOfLines = useMemo(() => {
return (
order.items?.reduce((acc, item) => {
return acc + item.quantity
}, 0) ?? 0
)
}, [order])
const numberOfProducts = useMemo(() => {
return order.items?.length ?? 0
}, [order])
return (
<div className="bg-white flex flex-col" data-testid="order-card">
<div className="uppercase text-large-semi mb-1">
#<span data-testid="order-display-id">{order.display_id}</span>
</div>
<div className="flex items-center divide-x divide-gray-200 text-small-regular text-ui-fg-base">
<span className="pr-2" data-testid="order-created-at">
{new Date(order.created_at).toDateString()}
</span>
<span className="px-2" data-testid="order-amount">
{convertToLocale({
amount: order.total,
currency_code: order.currency_code,
})}
</span>
<span className="pl-2">{`${numberOfLines} ${
numberOfLines > 1 ? "items" : "item"
}`}</span>
</div>
<div className="grid grid-cols-2 small:grid-cols-4 gap-4 my-4">
{order.items?.slice(0, 3).map((i) => {
return (
<div
key={i.id}
className="flex flex-col gap-y-2"
data-testid="order-item"
>
<Thumbnail thumbnail={i.thumbnail} images={[]} size="full" />
<div className="flex items-center text-small-regular text-ui-fg-base">
<span
className="text-ui-fg-base font-semibold"
data-testid="item-title"
>
{i.title}
</span>
<span className="ml-2">x</span>
<span data-testid="item-quantity">{i.quantity}</span>
</div>
</div>
)
})}
{numberOfProducts > 4 && (
<div className="w-full h-full flex flex-col items-center justify-center">
<span className="text-small-regular text-ui-fg-base">
+ {numberOfLines - 4}
</span>
<span className="text-small-regular text-ui-fg-base">more</span>
</div>
)}
</div>
<div className="flex justify-end">
<LocalizedClientLink href={`/account/orders/details/${order.id}`}>
<Button data-testid="order-details-link" variant="secondary">
See details
</Button>
</LocalizedClientLink>
</div>
</div>
)
}
export default OrderCard

View File

@@ -0,0 +1,45 @@
"use client"
import { Button } from "@medusajs/ui"
import OrderCard from "../order-card"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { HttpTypes } from "@medusajs/types"
const OrderOverview = ({ orders }: { orders: HttpTypes.StoreOrder[] }) => {
if (orders?.length) {
return (
<div className="flex flex-col gap-y-8 w-full">
{orders.map((o) => (
<div
key={o.id}
className="border-b border-gray-200 pb-6 last:pb-0 last:border-none"
>
<OrderCard order={o} />
</div>
))}
</div>
)
}
return (
<div
className="w-full flex flex-col items-center gap-y-4"
data-testid="no-orders-container"
>
<h2 className="text-large-semi">Nothing to see here</h2>
<p className="text-base-regular">
You don&apos;t have any orders yet, let us change that {":)"}
</p>
<div className="mt-4">
<LocalizedClientLink href="/" passHref>
<Button data-testid="continue-shopping-button">
Continue shopping
</Button>
</LocalizedClientLink>
</div>
</div>
)
}
export default OrderOverview

View File

@@ -0,0 +1,168 @@
import { Container } from "@medusajs/ui"
import ChevronDown from "@modules/common/icons/chevron-down"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { convertToLocale } from "@lib/util/money"
import { HttpTypes } from "@medusajs/types"
type OverviewProps = {
customer: HttpTypes.StoreCustomer | null
orders: HttpTypes.StoreOrder[] | null
}
const Overview = ({ customer, orders }: OverviewProps) => {
return (
<div data-testid="overview-page-wrapper">
<div className="hidden small:block">
<div className="text-xl-semi flex justify-between items-center mb-4">
<span data-testid="welcome-message" data-value={customer?.first_name}>
Hello {customer?.first_name}
</span>
<span className="text-small-regular text-ui-fg-base">
Signed in as:{" "}
<span
className="font-semibold"
data-testid="customer-email"
data-value={customer?.email}
>
{customer?.email}
</span>
</span>
</div>
<div className="flex flex-col py-8 border-t border-gray-200">
<div className="flex flex-col gap-y-4 h-full col-span-1 row-span-2 flex-1">
<div className="flex items-start gap-x-16 mb-6">
<div className="flex flex-col gap-y-4">
<h3 className="text-large-semi">Profile</h3>
<div className="flex items-end gap-x-2">
<span
className="text-3xl-semi leading-none"
data-testid="customer-profile-completion"
data-value={getProfileCompletion(customer)}
>
{getProfileCompletion(customer)}%
</span>
<span className="uppercase text-base-regular text-ui-fg-subtle">
Completed
</span>
</div>
</div>
<div className="flex flex-col gap-y-4">
<h3 className="text-large-semi">Addresses</h3>
<div className="flex items-end gap-x-2">
<span
className="text-3xl-semi leading-none"
data-testid="addresses-count"
data-value={customer?.addresses?.length || 0}
>
{customer?.addresses?.length || 0}
</span>
<span className="uppercase text-base-regular text-ui-fg-subtle">
Saved
</span>
</div>
</div>
</div>
<div className="flex flex-col gap-y-4">
<div className="flex items-center gap-x-2">
<h3 className="text-large-semi">Recent orders</h3>
</div>
<ul
className="flex flex-col gap-y-4"
data-testid="orders-wrapper"
>
{orders && orders.length > 0 ? (
orders.slice(0, 5).map((order) => {
return (
<li
key={order.id}
data-testid="order-wrapper"
data-value={order.id}
>
<LocalizedClientLink
href={`/account/orders/details/${order.id}`}
>
<Container className="bg-gray-50 flex justify-between items-center p-4">
<div className="grid grid-cols-3 grid-rows-2 text-small-regular gap-x-4 flex-1">
<span className="font-semibold">Date placed</span>
<span className="font-semibold">
Order number
</span>
<span className="font-semibold">
Total amount
</span>
<span data-testid="order-created-date">
{new Date(order.created_at).toDateString()}
</span>
<span
data-testid="order-id"
data-value={order.display_id}
>
#{order.display_id}
</span>
<span data-testid="order-amount">
{convertToLocale({
amount: order.total,
currency_code: order.currency_code,
})}
</span>
</div>
<button
className="flex items-center justify-between"
data-testid="open-order-button"
>
<span className="sr-only">
Go to order #{order.display_id}
</span>
<ChevronDown className="-rotate-90" />
</button>
</Container>
</LocalizedClientLink>
</li>
)
})
) : (
<span data-testid="no-orders-message">No recent orders</span>
)}
</ul>
</div>
</div>
</div>
</div>
</div>
)
}
const getProfileCompletion = (customer: HttpTypes.StoreCustomer | null) => {
let count = 0
if (!customer) {
return 0
}
if (customer.email) {
count++
}
if (customer.first_name && customer.last_name) {
count++
}
if (customer.phone) {
count++
}
const billingAddress = customer.addresses?.find(
(addr) => addr.is_default_billing
)
if (billingAddress) {
count++
}
return (count / 4) * 100
}
export default Overview

View File

@@ -0,0 +1,182 @@
"use client"
import React, { useEffect, useMemo, useActionState } from "react"
import Input from "@modules/common/components/input"
import NativeSelect from "@modules/common/components/native-select"
import AccountInfo from "../account-info"
import { HttpTypes } from "@medusajs/types"
import { addCustomerAddress, updateCustomerAddress } from "@lib/data/customer"
type MyInformationProps = {
customer: HttpTypes.StoreCustomer
regions: HttpTypes.StoreRegion[]
}
const ProfileBillingAddress: React.FC<MyInformationProps> = ({
customer,
regions,
}) => {
const regionOptions = useMemo(() => {
return (
regions
?.map((region) => {
return region.countries?.map((country) => ({
value: country.iso_2,
label: country.display_name,
}))
})
.flat() || []
)
}, [regions])
const [successState, setSuccessState] = React.useState(false)
const billingAddress = customer.addresses?.find(
(addr) => addr.is_default_billing
)
const initialState: Record<string, any> = {
isDefaultBilling: true,
isDefaultShipping: false,
error: false,
success: false,
}
if (billingAddress) {
initialState.addressId = billingAddress.id
}
const [state, formAction] = useActionState(
billingAddress ? updateCustomerAddress : addCustomerAddress,
initialState
)
const clearState = () => {
setSuccessState(false)
}
useEffect(() => {
setSuccessState(state.success)
}, [state])
const currentInfo = useMemo(() => {
if (!billingAddress) {
return "No billing address"
}
const country =
regionOptions?.find(
(country) => country?.value === billingAddress.country_code
)?.label || billingAddress.country_code?.toUpperCase()
return (
<div className="flex flex-col font-semibold" data-testid="current-info">
<span>
{billingAddress.first_name} {billingAddress.last_name}
</span>
<span>{billingAddress.company}</span>
<span>
{billingAddress.address_1}
{billingAddress.address_2 ? `, ${billingAddress.address_2}` : ""}
</span>
<span>
{billingAddress.postal_code}, {billingAddress.city}
</span>
<span>{country}</span>
</div>
)
}, [billingAddress, regionOptions])
return (
<form action={formAction} onReset={() => clearState()} className="w-full">
<input type="hidden" name="addressId" value={billingAddress?.id} />
<AccountInfo
label="Billing address"
currentInfo={currentInfo}
isSuccess={successState}
isError={!!state.error}
clearState={clearState}
data-testid="account-billing-address-editor"
>
<div className="grid grid-cols-1 gap-y-2">
<div className="grid grid-cols-2 gap-x-2">
<Input
label="First name"
name="first_name"
defaultValue={billingAddress?.first_name || undefined}
required
data-testid="billing-first-name-input"
/>
<Input
label="Last name"
name="last_name"
defaultValue={billingAddress?.last_name || undefined}
required
data-testid="billing-last-name-input"
/>
</div>
<Input
label="Company"
name="company"
defaultValue={billingAddress?.company || undefined}
data-testid="billing-company-input"
/>
<Input
label="Address"
name="address_1"
defaultValue={billingAddress?.address_1 || undefined}
required
data-testid="billing-address-1-input"
/>
<Input
label="Apartment, suite, etc."
name="address_2"
defaultValue={billingAddress?.address_2 || undefined}
data-testid="billing-address-2-input"
/>
<div className="grid grid-cols-[144px_1fr] gap-x-2">
<Input
label="Postal code"
name="postal_code"
defaultValue={billingAddress?.postal_code || undefined}
required
data-testid="billing-postcal-code-input"
/>
<Input
label="City"
name="city"
defaultValue={billingAddress?.city || undefined}
required
data-testid="billing-city-input"
/>
</div>
<Input
label="Province"
name="province"
defaultValue={billingAddress?.province || undefined}
data-testid="billing-province-input"
/>
<NativeSelect
name="country_code"
defaultValue={billingAddress?.country_code || undefined}
required
data-testid="billing-country-code-select"
>
<option value="">-</option>
{regionOptions.map((option, i) => {
return (
<option key={i} value={option?.value}>
{option?.label}
</option>
)
})}
</NativeSelect>
</div>
</AccountInfo>
</form>
)
}
export default ProfileBillingAddress

View File

@@ -0,0 +1,75 @@
"use client"
import React, { useEffect, useActionState } from "react";
import Input from "@modules/common/components/input"
import AccountInfo from "../account-info"
import { HttpTypes } from "@medusajs/types"
// import { updateCustomer } from "@lib/data/customer"
type MyInformationProps = {
customer: HttpTypes.StoreCustomer
}
const ProfileEmail: React.FC<MyInformationProps> = ({ customer }) => {
const [successState, setSuccessState] = React.useState(false)
// TODO: It seems we don't support updating emails now?
const updateCustomerEmail = (
_currentState: Record<string, unknown>,
formData: FormData
) => {
const customer = {
email: formData.get("email") as string,
}
try {
// await updateCustomer(customer)
return { success: true, error: null }
} catch (error: any) {
return { success: false, error: error.toString() }
}
}
const [state, formAction] = useActionState(updateCustomerEmail, {
error: false,
success: false,
})
const clearState = () => {
setSuccessState(false)
}
useEffect(() => {
setSuccessState(state.success)
}, [state])
return (
<form action={formAction} className="w-full">
<AccountInfo
label="Email"
currentInfo={`${customer.email}`}
isSuccess={successState}
isError={!!state.error}
errorMessage={state.error}
clearState={clearState}
data-testid="account-email-editor"
>
<div className="grid grid-cols-1 gap-y-2">
<Input
label="Email"
name="email"
type="email"
autoComplete="email"
required
defaultValue={customer.email}
data-testid="email-input"
/>
</div>
</AccountInfo>
</form>
)
}
export default ProfileEmail

View File

@@ -0,0 +1,79 @@
"use client"
import React, { useEffect, useActionState } from "react";
import Input from "@modules/common/components/input"
import AccountInfo from "../account-info"
import { HttpTypes } from "@medusajs/types"
import { updateCustomer } from "@lib/data/customer"
type MyInformationProps = {
customer: HttpTypes.StoreCustomer
}
const ProfileName: React.FC<MyInformationProps> = ({ customer }) => {
const [successState, setSuccessState] = React.useState(false)
const updateCustomerName = async (
_currentState: Record<string, unknown>,
formData: FormData
) => {
const customer = {
first_name: formData.get("first_name") as string,
last_name: formData.get("last_name") as string,
}
try {
await updateCustomer(customer)
return { success: true, error: null }
} catch (error: any) {
return { success: false, error: error.toString() }
}
}
const [state, formAction] = useActionState(updateCustomerName, {
error: false,
success: false,
})
const clearState = () => {
setSuccessState(false)
}
useEffect(() => {
setSuccessState(state.success)
}, [state])
return (
<form action={formAction} className="w-full overflow-visible">
<AccountInfo
label="Name"
currentInfo={`${customer.first_name} ${customer.last_name}`}
isSuccess={successState}
isError={!!state?.error}
clearState={clearState}
data-testid="account-name-editor"
>
<div className="grid grid-cols-2 gap-x-4">
<Input
label="First name"
name="first_name"
required
defaultValue={customer.first_name ?? ""}
data-testid="first-name-input"
/>
<Input
label="Last name"
name="last_name"
required
defaultValue={customer.last_name ?? ""}
data-testid="last-name-input"
/>
</div>
</AccountInfo>
</form>
)
}
export default ProfileName

View File

@@ -0,0 +1,70 @@
"use client"
import React, { useEffect, useActionState } from "react"
import Input from "@modules/common/components/input"
import AccountInfo from "../account-info"
import { HttpTypes } from "@medusajs/types"
import { toast } from "@medusajs/ui"
type MyInformationProps = {
customer: HttpTypes.StoreCustomer
}
const ProfilePassword: React.FC<MyInformationProps> = ({ customer }) => {
const [successState, setSuccessState] = React.useState(false)
// TODO: Add support for password updates
const updatePassword = async () => {
toast.info("Password update is not implemented")
}
const clearState = () => {
setSuccessState(false)
}
return (
<form
action={updatePassword}
onReset={() => clearState()}
className="w-full"
>
<AccountInfo
label="Password"
currentInfo={
<span>The password is not shown for security reasons</span>
}
isSuccess={successState}
isError={false}
errorMessage={undefined}
clearState={clearState}
data-testid="account-password-editor"
>
<div className="grid grid-cols-2 gap-4">
<Input
label="Old password"
name="old_password"
required
type="password"
data-testid="old-password-input"
/>
<Input
label="New password"
type="password"
name="new_password"
required
data-testid="new-password-input"
/>
<Input
label="Confirm password"
type="password"
name="confirm_password"
required
data-testid="confirm-password-input"
/>
</div>
</AccountInfo>
</form>
)
}
export default ProfilePassword

View File

@@ -0,0 +1,74 @@
"use client"
import React, { useEffect, useActionState } from "react";
import Input from "@modules/common/components/input"
import AccountInfo from "../account-info"
import { HttpTypes } from "@medusajs/types"
import { updateCustomer } from "@lib/data/customer"
type MyInformationProps = {
customer: HttpTypes.StoreCustomer
}
const ProfileEmail: React.FC<MyInformationProps> = ({ customer }) => {
const [successState, setSuccessState] = React.useState(false)
const updateCustomerPhone = async (
_currentState: Record<string, unknown>,
formData: FormData
) => {
const customer = {
phone: formData.get("phone") as string,
}
try {
await updateCustomer(customer)
return { success: true, error: null }
} catch (error: any) {
return { success: false, error: error.toString() }
}
}
const [state, formAction] = useActionState(updateCustomerPhone, {
error: false,
success: false,
})
const clearState = () => {
setSuccessState(false)
}
useEffect(() => {
setSuccessState(state.success)
}, [state])
return (
<form action={formAction} className="w-full">
<AccountInfo
label="Phone"
currentInfo={`${customer.phone}`}
isSuccess={successState}
isError={!!state.error}
errorMessage={state.error}
clearState={clearState}
data-testid="account-phone-editor"
>
<div className="grid grid-cols-1 gap-y-2">
<Input
label="Phone"
name="phone"
type="phone"
autoComplete="phone"
required
defaultValue={customer.phone ?? ""}
data-testid="phone-input"
/>
</div>
</AccountInfo>
</form>
)
}
export default ProfileEmail

View File

@@ -0,0 +1,106 @@
"use client"
import { useActionState } from "react"
import Input from "@modules/common/components/input"
import { LOGIN_VIEW } from "@modules/account/templates/login-template"
import ErrorMessage from "@modules/checkout/components/error-message"
import { SubmitButton } from "@modules/checkout/components/submit-button"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { signup } from "@lib/data/customer"
type Props = {
setCurrentView: (view: LOGIN_VIEW) => void
}
const Register = ({ setCurrentView }: Props) => {
const [message, formAction] = useActionState(signup, null)
return (
<div
className="max-w-sm flex flex-col items-center"
data-testid="register-page"
>
<h1 className="text-large-semi uppercase mb-6">
Become a Medusa Store Member
</h1>
<p className="text-center text-base-regular text-ui-fg-base mb-4">
Create your Medusa Store Member profile, and get access to an enhanced
shopping experience.
</p>
<form className="w-full flex flex-col" action={formAction}>
<div className="flex flex-col w-full gap-y-2">
<Input
label="First name"
name="first_name"
required
autoComplete="given-name"
data-testid="first-name-input"
/>
<Input
label="Last name"
name="last_name"
required
autoComplete="family-name"
data-testid="last-name-input"
/>
<Input
label="Email"
name="email"
required
type="email"
autoComplete="email"
data-testid="email-input"
/>
<Input
label="Phone"
name="phone"
type="tel"
autoComplete="tel"
data-testid="phone-input"
/>
<Input
label="Password"
name="password"
required
type="password"
autoComplete="new-password"
data-testid="password-input"
/>
</div>
<ErrorMessage error={message} data-testid="register-error" />
<span className="text-center text-ui-fg-base text-small-regular mt-6">
By creating an account, you agree to Medusa Store&apos;s{" "}
<LocalizedClientLink
href="/content/privacy-policy"
className="underline"
>
Privacy Policy
</LocalizedClientLink>{" "}
and{" "}
<LocalizedClientLink
href="/content/terms-of-use"
className="underline"
>
Terms of Use
</LocalizedClientLink>
.
</span>
<SubmitButton className="w-full mt-6" data-testid="register-button">
Join
</SubmitButton>
</form>
<span className="text-center text-ui-fg-base text-small-regular mt-6">
Already a member?{" "}
<button
onClick={() => setCurrentView(LOGIN_VIEW.SIGN_IN)}
className="underline"
>
Sign in
</button>
.
</span>
</div>
)
}
export default Register

View File

@@ -0,0 +1,81 @@
"use client"
import { useActionState } from "react"
import { createTransferRequest } from "@lib/data/orders"
import { Text, Heading, Input, Button, IconButton, Toaster } from "@medusajs/ui"
import { SubmitButton } from "@modules/checkout/components/submit-button"
import { CheckCircleMiniSolid, XCircleSolid } from "@medusajs/icons"
import { useEffect, useState } from "react"
export default function TransferRequestForm() {
const [showSuccess, setShowSuccess] = useState(false)
const [state, formAction] = useActionState(createTransferRequest, {
success: false,
error: null,
order: null,
})
useEffect(() => {
if (state.success && state.order) {
setShowSuccess(true)
}
}, [state.success, state.order])
return (
<div className="flex flex-col gap-y-4 w-full">
<div className="grid sm:grid-cols-2 items-center gap-x-8 gap-y-4 w-full">
<div className="flex flex-col gap-y-1">
<Heading level="h3" className="text-lg text-neutral-950">
Order transfers
</Heading>
<Text className="text-base-regular text-neutral-500">
Can&apos;t find the order you are looking for?
<br /> Connect an order to your account.
</Text>
</div>
<form
action={formAction}
className="flex flex-col gap-y-1 sm:items-end"
>
<div className="flex flex-col gap-y-2 w-full">
<Input className="w-full" name="order_id" placeholder="Order ID" />
<SubmitButton
variant="secondary"
className="w-fit whitespace-nowrap self-end"
>
Request transfer
</SubmitButton>
</div>
</form>
</div>
{!state.success && state.error && (
<Text className="text-base-regular text-rose-500 text-right">
{state.error}
</Text>
)}
{showSuccess && (
<div className="flex justify-between p-4 bg-neutral-50 shadow-borders-base w-full self-stretch items-center">
<div className="flex gap-x-2 items-center">
<CheckCircleMiniSolid className="w-4 h-4 text-emerald-500" />
<div className="flex flex-col gap-y-1">
<Text className="text-medim-pl text-neutral-950">
Transfer for order {state.order?.id} requested
</Text>
<Text className="text-base-regular text-neutral-600">
Transfer request email sent to {state.order?.email}
</Text>
</div>
</div>
<IconButton
variant="transparent"
className="h-fit"
onClick={() => setShowSuccess(false)}
>
<XCircleSolid className="w-4 h-4 text-neutral-500" />
</IconButton>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,43 @@
import React from "react"
import UnderlineLink from "@modules/common/components/interactive-link"
import AccountNav from "../components/account-nav"
import { HttpTypes } from "@medusajs/types"
interface AccountLayoutProps {
customer: HttpTypes.StoreCustomer | null
children: React.ReactNode
}
const AccountLayout: React.FC<AccountLayoutProps> = ({
customer,
children,
}) => {
return (
<div className="flex-1 small:py-12" data-testid="account-page">
<div className="flex-1 content-container h-full max-w-5xl mx-auto bg-white flex flex-col">
<div className="grid grid-cols-1 small:grid-cols-[240px_1fr] py-12">
<div>{customer && <AccountNav customer={customer} />}</div>
<div className="flex-1">{children}</div>
</div>
<div className="flex flex-col small:flex-row items-end justify-between small:border-t border-gray-200 py-12 gap-8">
<div>
<h3 className="text-xl-semi mb-4">Got questions?</h3>
<span className="txt-medium">
You can find frequently asked questions and answers on our
customer service page.
</span>
</div>
<div>
<UnderlineLink href="/customer-service">
Customer Service
</UnderlineLink>
</div>
</div>
</div>
</div>
)
}
export default AccountLayout

View File

@@ -0,0 +1,7 @@
// account-info
export { default as AccountLayout } from "./account-layout";
export * from "./account-layout";
// account-nav
export { default as LoginTemplate } from "./login-template";
export * from "./login-template";

View File

@@ -0,0 +1,25 @@
"use client";
import { useState } from "react";
import { Login, Register } from "../components";
export enum LOGIN_VIEW {
SIGN_IN = "sign-in",
REGISTER = "register",
}
const LoginTemplate = () => {
const [currentView, setCurrentView] = useState("sign-in");
return (
<div className="w-full flex justify-start px-8 py-8">
{currentView === "sign-in" ? (
<Login setCurrentView={setCurrentView} />
) : (
<Register setCurrentView={setCurrentView} />
)}
</div>
);
};
export default LoginTemplate;

View File

@@ -0,0 +1,73 @@
"use client"
import { IconBadge, clx } from "@medusajs/ui"
import {
SelectHTMLAttributes,
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import ChevronDown from "@modules/common/icons/chevron-down"
type NativeSelectProps = {
placeholder?: string
errors?: Record<string, unknown>
touched?: Record<string, unknown>
} & Omit<SelectHTMLAttributes<HTMLSelectElement>, "size">
const CartItemSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
({ placeholder = "Select...", className, children, ...props }, ref) => {
const innerRef = useRef<HTMLSelectElement>(null)
const [isPlaceholder, setIsPlaceholder] = useState(false)
useImperativeHandle<HTMLSelectElement | null, HTMLSelectElement | null>(
ref,
() => innerRef.current
)
useEffect(() => {
if (innerRef.current && innerRef.current.value === "") {
setIsPlaceholder(true)
} else {
setIsPlaceholder(false)
}
}, [innerRef.current?.value])
return (
<div>
<IconBadge
onFocus={() => innerRef.current?.focus()}
onBlur={() => innerRef.current?.blur()}
className={clx(
"relative flex items-center txt-compact-small border text-ui-fg-base group",
className,
{
"text-ui-fg-subtle": isPlaceholder,
}
)}
>
<select
ref={innerRef}
{...props}
className="appearance-none bg-transparent border-none px-4 transition-colors duration-150 focus:border-gray-700 outline-none w-16 h-16 items-center justify-center"
>
<option disabled value="">
{placeholder}
</option>
{children}
</select>
<span className="absolute flex pointer-events-none justify-end w-8 group-hover:animate-pulse">
<ChevronDown />
</span>
</IconBadge>
</div>
)
}
)
CartItemSelect.displayName = "CartItemSelect"
export default CartItemSelect

View File

@@ -0,0 +1,25 @@
import { Heading, Text } from "@medusajs/ui"
import InteractiveLink from "@modules/common/components/interactive-link"
const EmptyCartMessage = () => {
return (
<div className="py-48 px-2 flex flex-col justify-center items-start" data-testid="empty-cart-message">
<Heading
level="h1"
className="flex flex-row text-3xl-regular gap-x-2 items-baseline"
>
Cart
</Heading>
<Text className="text-base-regular mt-4 mb-6 max-w-[32rem]">
You don&apos;t have anything in your cart. Let&apos;s change that, use
the link below to start browsing our products.
</Text>
<div>
<InteractiveLink href="/store">Explore products</InteractiveLink>
</div>
</div>
)
}
export default EmptyCartMessage

View File

@@ -0,0 +1,144 @@
"use client"
import { Table, Text, clx } from "@medusajs/ui"
import { updateLineItem } from "@lib/data/cart"
import { HttpTypes } from "@medusajs/types"
import CartItemSelect from "@modules/cart/components/cart-item-select"
import ErrorMessage from "@modules/checkout/components/error-message"
import DeleteButton from "@modules/common/components/delete-button"
import LineItemOptions from "@modules/common/components/line-item-options"
import LineItemPrice from "@modules/common/components/line-item-price"
import LineItemUnitPrice from "@modules/common/components/line-item-unit-price"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import Spinner from "@modules/common/icons/spinner"
import Thumbnail from "@modules/products/components/thumbnail"
import { useState } from "react"
type ItemProps = {
item: HttpTypes.StoreCartLineItem
type?: "full" | "preview"
currencyCode: string
}
const Item = ({ item, type = "full", currencyCode }: ItemProps) => {
const [updating, setUpdating] = useState(false)
const [error, setError] = useState<string | null>(null)
const changeQuantity = async (quantity: number) => {
setError(null)
setUpdating(true)
await updateLineItem({
lineId: item.id,
quantity,
})
.catch((err) => {
setError(err.message)
})
.finally(() => {
setUpdating(false)
})
}
// TODO: Update this to grab the actual max inventory
const maxQtyFromInventory = 10
const maxQuantity = item.variant?.manage_inventory ? 10 : maxQtyFromInventory
return (
<Table.Row className="w-full" data-testid="product-row">
<Table.Cell className="!pl-0 p-4 w-24">
<LocalizedClientLink
href={`/products/${item.product_handle}`}
className={clx("flex", {
"w-16": type === "preview",
"small:w-24 w-12": type === "full",
})}
>
<Thumbnail
thumbnail={item.thumbnail}
images={item.variant?.product?.images}
size="square"
/>
</LocalizedClientLink>
</Table.Cell>
<Table.Cell className="text-left">
<Text
className="txt-medium-plus text-ui-fg-base"
data-testid="product-title"
>
{item.product_title}
</Text>
<LineItemOptions variant={item.variant} data-testid="product-variant" />
</Table.Cell>
{type === "full" && (
<Table.Cell>
<div className="flex gap-2 items-center w-28">
<DeleteButton id={item.id} data-testid="product-delete-button" />
<CartItemSelect
value={item.quantity}
onChange={(value) => changeQuantity(parseInt(value.target.value))}
className="w-14 h-10 p-4"
data-testid="product-select-button"
>
{/* TODO: Update this with the v2 way of managing inventory */}
{Array.from(
{
length: Math.min(maxQuantity, 10),
},
(_, i) => (
<option value={i + 1} key={i}>
{i + 1}
</option>
)
)}
<option value={1} key={1}>
1
</option>
</CartItemSelect>
{updating && <Spinner />}
</div>
<ErrorMessage error={error} data-testid="product-error-message" />
</Table.Cell>
)}
{type === "full" && (
<Table.Cell className="hidden small:table-cell">
<LineItemUnitPrice
item={item}
style="tight"
currencyCode={currencyCode}
/>
</Table.Cell>
)}
<Table.Cell className="!pr-0">
<span
className={clx("!pr-0", {
"flex flex-col items-end h-full justify-center": type === "preview",
})}
>
{type === "preview" && (
<span className="flex gap-x-1 ">
<Text className="text-ui-fg-muted">{item.quantity}x </Text>
<LineItemUnitPrice
item={item}
style="tight"
currencyCode={currencyCode}
/>
</span>
)}
<LineItemPrice
item={item}
style="tight"
currencyCode={currencyCode}
/>
</span>
</Table.Cell>
</Table.Row>
)
}
export default Item

View File

@@ -0,0 +1,26 @@
import { Button, Heading, Text } from "@medusajs/ui"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
const SignInPrompt = () => {
return (
<div className="bg-white flex items-center justify-between">
<div>
<Heading level="h2" className="txt-xlarge">
Already have an account?
</Heading>
<Text className="txt-medium text-ui-fg-subtle mt-2">
Sign in for a better experience.
</Text>
</div>
<div>
<LocalizedClientLink href="/account">
<Button variant="secondary" className="h-10" data-testid="sign-in-button">
Sign in
</Button>
</LocalizedClientLink>
</div>
</div>
)
}
export default SignInPrompt

View File

@@ -0,0 +1,51 @@
import ItemsTemplate from "./items"
import Summary from "./summary"
import EmptyCartMessage from "../components/empty-cart-message"
import SignInPrompt from "../components/sign-in-prompt"
import Divider from "@modules/common/components/divider"
import { HttpTypes } from "@medusajs/types"
const CartTemplate = ({
cart,
customer,
}: {
cart: HttpTypes.StoreCart | null
customer: HttpTypes.StoreCustomer | null
}) => {
return (
<div className="py-12">
<div className="content-container" data-testid="cart-container">
{cart?.items?.length ? (
<div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40">
<div className="flex flex-col bg-white py-6 gap-y-6">
{!customer && (
<>
<SignInPrompt />
<Divider />
</>
)}
<ItemsTemplate cart={cart} />
</div>
<div className="relative">
<div className="flex flex-col gap-y-8 sticky top-12">
{cart && cart.region && (
<>
<div className="bg-white py-6">
<Summary cart={cart as any} />
</div>
</>
)}
</div>
</div>
</div>
) : (
<div>
<EmptyCartMessage />
</div>
)}
</div>
</div>
)
}
export default CartTemplate

View File

@@ -0,0 +1,57 @@
import repeat from "@lib/util/repeat"
import { HttpTypes } from "@medusajs/types"
import { Heading, Table } from "@medusajs/ui"
import Item from "@modules/cart/components/item"
import SkeletonLineItem from "@modules/skeletons/components/skeleton-line-item"
type ItemsTemplateProps = {
cart?: HttpTypes.StoreCart
}
const ItemsTemplate = ({ cart }: ItemsTemplateProps) => {
const items = cart?.items
return (
<div>
<div className="pb-3 flex items-center">
<Heading className="text-[2rem] leading-[2.75rem]">Cart</Heading>
</div>
<Table>
<Table.Header className="border-t-0">
<Table.Row className="text-ui-fg-subtle txt-medium-plus">
<Table.HeaderCell className="!pl-0">Item</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell>Quantity</Table.HeaderCell>
<Table.HeaderCell className="hidden small:table-cell">
Price
</Table.HeaderCell>
<Table.HeaderCell className="!pr-0 text-right">
Total
</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{items
? items
.sort((a, b) => {
return (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1
})
.map((item) => {
return (
<Item
key={item.id}
item={item}
currencyCode={cart?.currency_code}
/>
)
})
: repeat(5).map((i) => {
return <SkeletonLineItem key={i} />
})}
</Table.Body>
</Table>
</div>
)
}
export default ItemsTemplate

View File

@@ -0,0 +1,51 @@
"use client"
import repeat from "@lib/util/repeat"
import { HttpTypes } from "@medusajs/types"
import { Table, clx } from "@medusajs/ui"
import Item from "@modules/cart/components/item"
import SkeletonLineItem from "@modules/skeletons/components/skeleton-line-item"
type ItemsTemplateProps = {
cart: HttpTypes.StoreCart
}
const ItemsPreviewTemplate = ({ cart }: ItemsTemplateProps) => {
const items = cart.items
const hasOverflow = items && items.length > 4
return (
<div
className={clx({
"pl-[1px] overflow-y-scroll overflow-x-hidden no-scrollbar max-h-[420px]":
hasOverflow,
})}
>
<Table>
<Table.Body data-testid="items-table">
{items
? items
.sort((a, b) => {
return (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1
})
.map((item) => {
return (
<Item
key={item.id}
item={item}
type="preview"
currencyCode={cart.currency_code}
/>
)
})
: repeat(5).map((i) => {
return <SkeletonLineItem key={i} />
})}
</Table.Body>
</Table>
</div>
)
}
export default ItemsPreviewTemplate

View File

@@ -0,0 +1,48 @@
"use client"
import { Button, Heading } from "@medusajs/ui"
import CartTotals from "@modules/common/components/cart-totals"
import Divider from "@modules/common/components/divider"
import DiscountCode from "@modules/checkout/components/discount-code"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { HttpTypes } from "@medusajs/types"
type SummaryProps = {
cart: HttpTypes.StoreCart & {
promotions: HttpTypes.StorePromotion[]
}
}
function getCheckoutStep(cart: HttpTypes.StoreCart) {
if (!cart?.shipping_address?.address_1 || !cart.email) {
return "address"
} else if (cart?.shipping_methods?.length === 0) {
return "delivery"
} else {
return "payment"
}
}
const Summary = ({ cart }: SummaryProps) => {
const step = getCheckoutStep(cart)
return (
<div className="flex flex-col gap-y-4">
<Heading level="h2" className="text-[2rem] leading-[2.75rem]">
Summary
</Heading>
<DiscountCode cart={cart} />
<Divider />
<CartTotals totals={cart} />
<LocalizedClientLink
href={"/checkout?step=" + step}
data-testid="checkout-button"
>
<Button className="w-full h-10">Go to checkout</Button>
</LocalizedClientLink>
</div>
)
}
export default Summary

View File

@@ -0,0 +1,97 @@
import { notFound } from "next/navigation"
import { Suspense } from "react"
import InteractiveLink from "@modules/common/components/interactive-link"
import SkeletonProductGrid from "@modules/skeletons/templates/skeleton-product-grid"
import RefinementList from "@modules/store/components/refinement-list"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
import PaginatedProducts from "@modules/store/templates/paginated-products"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { HttpTypes } from "@medusajs/types"
export default function CategoryTemplate({
category,
sortBy,
page,
countryCode,
}: {
category: HttpTypes.StoreProductCategory
sortBy?: SortOptions
page?: string
countryCode: string
}) {
const pageNumber = page ? parseInt(page) : 1
const sort = sortBy || "created_at"
if (!category || !countryCode) notFound()
const parents = [] as HttpTypes.StoreProductCategory[]
const getParents = (category: HttpTypes.StoreProductCategory) => {
if (category.parent_category) {
parents.push(category.parent_category)
getParents(category.parent_category)
}
}
getParents(category)
return (
<div
className="flex flex-col small:flex-row small:items-start py-6 content-container"
data-testid="category-container"
>
<RefinementList sortBy={sort} data-testid="sort-by-container" />
<div className="w-full">
<div className="flex flex-row mb-8 text-2xl-semi gap-4">
{parents &&
parents.map((parent) => (
<span key={parent.id} className="text-ui-fg-subtle">
<LocalizedClientLink
className="mr-4 hover:text-black"
href={`/categories/${parent.handle}`}
data-testid="sort-by-link"
>
{parent.name}
</LocalizedClientLink>
/
</span>
))}
<h1 data-testid="category-page-title">{category.name}</h1>
</div>
{category.description && (
<div className="mb-8 text-base-regular">
<p>{category.description}</p>
</div>
)}
{category.category_children && (
<div className="mb-8 text-base-large">
<ul className="grid grid-cols-1 gap-2">
{category.category_children?.map((c) => (
<li key={c.id}>
<InteractiveLink href={`/categories/${c.handle}`}>
{c.name}
</InteractiveLink>
</li>
))}
</ul>
</div>
)}
<Suspense
fallback={
<SkeletonProductGrid
numberOfProducts={category.products?.length ?? 8}
/>
}
>
<PaginatedProducts
sortBy={sort}
page={pageNumber}
categoryId={category.id}
countryCode={countryCode}
/>
</Suspense>
</div>
</div>
)
}

View File

@@ -0,0 +1,116 @@
import { Listbox, Transition } from "@headlessui/react"
import { ChevronUpDown } from "@medusajs/icons"
import { clx } from "@medusajs/ui"
import { Fragment, useMemo } from "react"
import Radio from "@modules/common/components/radio"
import compareAddresses from "@lib/util/compare-addresses"
import { HttpTypes } from "@medusajs/types"
type AddressSelectProps = {
addresses: HttpTypes.StoreCustomerAddress[]
addressInput: HttpTypes.StoreCartAddress | null
onSelect: (
address: HttpTypes.StoreCartAddress | undefined,
email?: string
) => void
}
const AddressSelect = ({
addresses,
addressInput,
onSelect,
}: AddressSelectProps) => {
const handleSelect = (id: string) => {
const savedAddress = addresses.find((a) => a.id === id)
if (savedAddress) {
onSelect(savedAddress as HttpTypes.StoreCartAddress)
}
}
const selectedAddress = useMemo(() => {
return addresses.find((a) => compareAddresses(a, addressInput))
}, [addresses, addressInput])
return (
<Listbox onChange={handleSelect} value={selectedAddress?.id}>
<div className="relative">
<Listbox.Button
className="relative w-full flex justify-between items-center px-4 py-[10px] text-left bg-white cursor-default focus:outline-none border rounded-rounded focus-visible:ring-2 focus-visible:ring-opacity-75 focus-visible:ring-white focus-visible:ring-offset-gray-300 focus-visible:ring-offset-2 focus-visible:border-gray-300 text-base-regular"
data-testid="shipping-address-select"
>
{({ open }) => (
<>
<span className="block truncate">
{selectedAddress
? selectedAddress.address_1
: "Choose an address"}
</span>
<ChevronUpDown
className={clx("transition-rotate duration-200", {
"transform rotate-180": open,
})}
/>
</>
)}
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options
className="absolute z-20 w-full overflow-auto text-small-regular bg-white border border-top-0 max-h-60 focus:outline-none sm:text-sm"
data-testid="shipping-address-options"
>
{addresses.map((address) => {
return (
<Listbox.Option
key={address.id}
value={address.id}
className="cursor-default select-none relative pl-6 pr-10 hover:bg-gray-50 py-4"
data-testid="shipping-address-option"
>
<div className="flex gap-x-4 items-start">
<Radio
checked={selectedAddress?.id === address.id}
data-testid="shipping-address-radio"
/>
<div className="flex flex-col">
<span className="text-left text-base-semi">
{address.first_name} {address.last_name}
</span>
{address.company && (
<span className="text-small-regular text-ui-fg-base">
{address.company}
</span>
)}
<div className="flex flex-col text-left text-base-regular mt-2">
<span>
{address.address_1}
{address.address_2 && (
<span>, {address.address_2}</span>
)}
</span>
<span>
{address.postal_code}, {address.city}
</span>
<span>
{address.province && `${address.province}, `}
{address.country_code?.toUpperCase()}
</span>
</div>
</div>
</div>
</Listbox.Option>
)
})}
</Listbox.Options>
</Transition>
</div>
</Listbox>
)
}
export default AddressSelect

View File

@@ -0,0 +1,184 @@
"use client"
import { setAddresses } from "@lib/data/cart"
import compareAddresses from "@lib/util/compare-addresses"
import { CheckCircleSolid } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Heading, Text, useToggleState } from "@medusajs/ui"
import Divider from "@modules/common/components/divider"
import Spinner from "@modules/common/icons/spinner"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useActionState } from "react"
import BillingAddress from "../billing_address"
import ErrorMessage from "../error-message"
import ShippingAddress from "../shipping-address"
import { SubmitButton } from "../submit-button"
const Addresses = ({
cart,
customer,
}: {
cart: HttpTypes.StoreCart | null
customer: HttpTypes.StoreCustomer | null
}) => {
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const isOpen = searchParams.get("step") === "address"
const { state: sameAsBilling, toggle: toggleSameAsBilling } = useToggleState(
cart?.shipping_address && cart?.billing_address
? compareAddresses(cart?.shipping_address, cart?.billing_address)
: true
)
const handleEdit = () => {
router.push(pathname + "?step=address")
}
const [message, formAction] = useActionState(setAddresses, null)
return (
<div className="bg-white">
<div className="flex flex-row items-center justify-between mb-6">
<Heading
level="h2"
className="flex flex-row text-3xl-regular gap-x-2 items-baseline"
>
Shipping Address
{!isOpen && <CheckCircleSolid />}
</Heading>
{!isOpen && cart?.shipping_address && (
<Text>
<button
onClick={handleEdit}
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
data-testid="edit-address-button"
>
Edit
</button>
</Text>
)}
</div>
{isOpen ? (
<form action={formAction}>
<div className="pb-8">
<ShippingAddress
customer={customer}
checked={sameAsBilling}
onChange={toggleSameAsBilling}
cart={cart}
/>
{!sameAsBilling && (
<div>
<Heading
level="h2"
className="text-3xl-regular gap-x-4 pb-6 pt-8"
>
Billing address
</Heading>
<BillingAddress cart={cart} />
</div>
)}
<SubmitButton className="mt-6" data-testid="submit-address-button">
Continue to delivery
</SubmitButton>
<ErrorMessage error={message} data-testid="address-error-message" />
</div>
</form>
) : (
<div>
<div className="text-small-regular">
{cart && cart.shipping_address ? (
<div className="flex items-start gap-x-8">
<div className="flex items-start gap-x-1 w-full">
<div
className="flex flex-col w-1/3"
data-testid="shipping-address-summary"
>
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Shipping Address
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.shipping_address.first_name}{" "}
{cart.shipping_address.last_name}
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.shipping_address.address_1}{" "}
{cart.shipping_address.address_2}
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.shipping_address.postal_code},{" "}
{cart.shipping_address.city}
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.shipping_address.country_code?.toUpperCase()}
</Text>
</div>
<div
className="flex flex-col w-1/3 "
data-testid="shipping-contact-summary"
>
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Contact
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.shipping_address.phone}
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.email}
</Text>
</div>
<div
className="flex flex-col w-1/3"
data-testid="billing-address-summary"
>
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Billing Address
</Text>
{sameAsBilling ? (
<Text className="txt-medium text-ui-fg-subtle">
Billing- and delivery address are the same.
</Text>
) : (
<>
<Text className="txt-medium text-ui-fg-subtle">
{cart.billing_address?.first_name}{" "}
{cart.billing_address?.last_name}
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.billing_address?.address_1}{" "}
{cart.billing_address?.address_2}
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.billing_address?.postal_code},{" "}
{cart.billing_address?.city}
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.billing_address?.country_code?.toUpperCase()}
</Text>
</>
)}
</div>
</div>
</div>
) : (
<div>
<Spinner />
</div>
)}
</div>
</div>
)}
<Divider className="mt-8" />
</div>
)
}
export default Addresses

View File

@@ -0,0 +1,114 @@
import { HttpTypes } from "@medusajs/types"
import Input from "@modules/common/components/input"
import React, { useState } from "react"
import CountrySelect from "../country-select"
const BillingAddress = ({ cart }: { cart: HttpTypes.StoreCart | null }) => {
const [formData, setFormData] = useState<any>({
"billing_address.first_name": cart?.billing_address?.first_name || "",
"billing_address.last_name": cart?.billing_address?.last_name || "",
"billing_address.address_1": cart?.billing_address?.address_1 || "",
"billing_address.company": cart?.billing_address?.company || "",
"billing_address.postal_code": cart?.billing_address?.postal_code || "",
"billing_address.city": cart?.billing_address?.city || "",
"billing_address.country_code": cart?.billing_address?.country_code || "",
"billing_address.province": cart?.billing_address?.province || "",
"billing_address.phone": cart?.billing_address?.phone || "",
})
const handleChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLInputElement | HTMLSelectElement
>
) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
})
}
return (
<>
<div className="grid grid-cols-2 gap-4">
<Input
label="First name"
name="billing_address.first_name"
autoComplete="given-name"
value={formData["billing_address.first_name"]}
onChange={handleChange}
required
data-testid="billing-first-name-input"
/>
<Input
label="Last name"
name="billing_address.last_name"
autoComplete="family-name"
value={formData["billing_address.last_name"]}
onChange={handleChange}
required
data-testid="billing-last-name-input"
/>
<Input
label="Address"
name="billing_address.address_1"
autoComplete="address-line1"
value={formData["billing_address.address_1"]}
onChange={handleChange}
required
data-testid="billing-address-input"
/>
<Input
label="Company"
name="billing_address.company"
value={formData["billing_address.company"]}
onChange={handleChange}
autoComplete="organization"
data-testid="billing-company-input"
/>
<Input
label="Postal code"
name="billing_address.postal_code"
autoComplete="postal-code"
value={formData["billing_address.postal_code"]}
onChange={handleChange}
required
data-testid="billing-postal-input"
/>
<Input
label="City"
name="billing_address.city"
autoComplete="address-level2"
value={formData["billing_address.city"]}
onChange={handleChange}
/>
<CountrySelect
name="billing_address.country_code"
autoComplete="country"
region={cart?.region}
value={formData["billing_address.country_code"]}
onChange={handleChange}
required
data-testid="billing-country-select"
/>
<Input
label="State / Province"
name="billing_address.province"
autoComplete="address-level1"
value={formData["billing_address.province"]}
onChange={handleChange}
data-testid="billing-province-input"
/>
<Input
label="Phone"
name="billing_address.phone"
autoComplete="tel"
value={formData["billing_address.phone"]}
onChange={handleChange}
data-testid="billing-phone-input"
/>
</div>
</>
)
}
export default BillingAddress

View File

@@ -0,0 +1,50 @@
import { forwardRef, useImperativeHandle, useMemo, useRef } from "react"
import NativeSelect, {
NativeSelectProps,
} from "@modules/common/components/native-select"
import { HttpTypes } from "@medusajs/types"
const CountrySelect = forwardRef<
HTMLSelectElement,
NativeSelectProps & {
region?: HttpTypes.StoreRegion
}
>(({ placeholder = "Country", region, defaultValue, ...props }, ref) => {
const innerRef = useRef<HTMLSelectElement>(null)
useImperativeHandle<HTMLSelectElement | null, HTMLSelectElement | null>(
ref,
() => innerRef.current
)
const countryOptions = useMemo(() => {
if (!region) {
return []
}
return region.countries?.map((country) => ({
value: country.iso_2,
label: country.display_name,
}))
}, [region])
return (
<NativeSelect
ref={innerRef}
placeholder={placeholder}
defaultValue={defaultValue}
{...props}
>
{countryOptions?.map(({ value, label }, index) => (
<option key={index} value={value}>
{label}
</option>
))}
</NativeSelect>
)
})
CountrySelect.displayName = "CountrySelect"
export default CountrySelect

View File

@@ -0,0 +1,175 @@
"use client"
import { Badge, Heading, Input, Label, Text, Tooltip } from "@medusajs/ui"
import React, { useActionState } from "react";
import { applyPromotions, submitPromotionForm } from "@lib/data/cart"
import { convertToLocale } from "@lib/util/money"
import { InformationCircleSolid } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import Trash from "@modules/common/icons/trash"
import ErrorMessage from "../error-message"
import { SubmitButton } from "../submit-button"
type DiscountCodeProps = {
cart: HttpTypes.StoreCart & {
promotions: HttpTypes.StorePromotion[]
}
}
const DiscountCode: React.FC<DiscountCodeProps> = ({ cart }) => {
const [isOpen, setIsOpen] = React.useState(false)
const { items = [], promotions = [] } = cart
const removePromotionCode = async (code: string) => {
const validPromotions = promotions.filter(
(promotion) => promotion.code !== code
)
await applyPromotions(
validPromotions.filter((p) => p.code === undefined).map((p) => p.code!)
)
}
const addPromotionCode = async (formData: FormData) => {
const code = formData.get("code")
if (!code) {
return
}
const input = document.getElementById("promotion-input") as HTMLInputElement
const codes = promotions
.filter((p) => p.code === undefined)
.map((p) => p.code!)
codes.push(code.toString())
await applyPromotions(codes)
if (input) {
input.value = ""
}
}
const [message, formAction] = useActionState(submitPromotionForm, null)
return (
<div className="w-full bg-white flex flex-col">
<div className="txt-medium">
<form action={(a) => addPromotionCode(a)} className="w-full mb-5">
<Label className="flex gap-x-1 my-2 items-center">
<button
onClick={() => setIsOpen(!isOpen)}
type="button"
className="txt-medium text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
data-testid="add-discount-button"
>
Add Promotion Code(s)
</button>
{/* <Tooltip content="You can add multiple promotion codes">
<InformationCircleSolid color="var(--fg-muted)" />
</Tooltip> */}
</Label>
{isOpen && (
<>
<div className="flex w-full gap-x-2">
<Input
className="size-full"
id="promotion-input"
name="code"
type="text"
autoFocus={false}
data-testid="discount-input"
/>
<SubmitButton
variant="secondary"
data-testid="discount-apply-button"
>
Apply
</SubmitButton>
</div>
<ErrorMessage
error={message}
data-testid="discount-error-message"
/>
</>
)}
</form>
{promotions.length > 0 && (
<div className="w-full flex items-center">
<div className="flex flex-col w-full">
<Heading className="txt-medium mb-2">
Promotion(s) applied:
</Heading>
{promotions.map((promotion) => {
return (
<div
key={promotion.id}
className="flex items-center justify-between w-full max-w-full mb-2"
data-testid="discount-row"
>
<Text className="flex gap-x-1 items-baseline txt-small-plus w-4/5 pr-1">
<span className="truncate" data-testid="discount-code">
<Badge
color={promotion.is_automatic ? "green" : "grey"}
size="small"
>
{promotion.code}
</Badge>{" "}
(
{promotion.application_method?.value !== undefined &&
promotion.application_method.currency_code !==
undefined && (
<>
{promotion.application_method.type ===
"percentage"
? `${promotion.application_method.value}%`
: convertToLocale({
amount: promotion.application_method.value,
currency_code:
promotion.application_method
.currency_code,
})}
</>
)}
)
{/* {promotion.is_automatic && (
<Tooltip content="This promotion is automatically applied">
<InformationCircleSolid className="inline text-zinc-400" />
</Tooltip>
)} */}
</span>
</Text>
{!promotion.is_automatic && (
<button
className="flex items-center"
onClick={() => {
if (!promotion.code) {
return
}
removePromotionCode(promotion.code)
}}
data-testid="remove-discount-button"
>
<Trash size={14} />
<span className="sr-only">
Remove discount code from order
</span>
</button>
)}
</div>
)
})}
</div>
</div>
)}
</div>
</div>
)
}
export default DiscountCode

View File

@@ -0,0 +1,13 @@
const ErrorMessage = ({ error, 'data-testid': dataTestid }: { error?: string | null, 'data-testid'?: string }) => {
if (!error) {
return null
}
return (
<div className="pt-2 text-rose-500 text-small-regular" data-testid={dataTestid}>
<span>{error}</span>
</div>
)
}
export default ErrorMessage

View File

@@ -0,0 +1,193 @@
"use client"
import { isManual, isStripe } from "@lib/constants"
import { placeOrder } from "@lib/data/cart"
import { HttpTypes } from "@medusajs/types"
import { Button } from "@medusajs/ui"
import { useElements, useStripe } from "@stripe/react-stripe-js"
import React, { useState } from "react"
import ErrorMessage from "../error-message"
type PaymentButtonProps = {
cart: HttpTypes.StoreCart
"data-testid": string
}
const PaymentButton: React.FC<PaymentButtonProps> = ({
cart,
"data-testid": dataTestId,
}) => {
const notReady =
!cart ||
!cart.shipping_address ||
!cart.billing_address ||
!cart.email ||
(cart.shipping_methods?.length ?? 0) < 1
const paymentSession = cart.payment_collection?.payment_sessions?.[0]
switch (true) {
case isStripe(paymentSession?.provider_id):
return (
<StripePaymentButton
notReady={notReady}
cart={cart}
data-testid={dataTestId}
/>
)
case isManual(paymentSession?.provider_id):
return (
<ManualTestPaymentButton notReady={notReady} data-testid={dataTestId} />
)
default:
return <Button disabled>Select a payment method</Button>
}
}
const StripePaymentButton = ({
cart,
notReady,
"data-testid": dataTestId,
}: {
cart: HttpTypes.StoreCart
notReady: boolean
"data-testid"?: string
}) => {
const [submitting, setSubmitting] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const onPaymentCompleted = async () => {
await placeOrder()
.catch((err) => {
setErrorMessage(err.message)
})
.finally(() => {
setSubmitting(false)
})
}
const stripe = useStripe()
const elements = useElements()
const card = elements?.getElement("card")
const session = cart.payment_collection?.payment_sessions?.find(
(s) => s.status === "pending"
)
const disabled = !stripe || !elements ? true : false
const handlePayment = async () => {
setSubmitting(true)
if (!stripe || !elements || !card || !cart) {
setSubmitting(false)
return
}
await stripe
.confirmCardPayment(session?.data.client_secret as string, {
payment_method: {
card: card,
billing_details: {
name:
cart.billing_address?.first_name +
" " +
cart.billing_address?.last_name,
address: {
city: cart.billing_address?.city ?? undefined,
country: cart.billing_address?.country_code ?? undefined,
line1: cart.billing_address?.address_1 ?? undefined,
line2: cart.billing_address?.address_2 ?? undefined,
postal_code: cart.billing_address?.postal_code ?? undefined,
state: cart.billing_address?.province ?? undefined,
},
email: cart.email,
phone: cart.billing_address?.phone ?? undefined,
},
},
})
.then(({ error, paymentIntent }) => {
if (error) {
const pi = error.payment_intent
if (
(pi && pi.status === "requires_capture") ||
(pi && pi.status === "succeeded")
) {
onPaymentCompleted()
}
setErrorMessage(error.message || null)
return
}
if (
(paymentIntent && paymentIntent.status === "requires_capture") ||
paymentIntent.status === "succeeded"
) {
return onPaymentCompleted()
}
return
})
}
return (
<>
<Button
disabled={disabled || notReady}
onClick={handlePayment}
size="large"
isLoading={submitting}
data-testid={dataTestId}
>
Place order
</Button>
<ErrorMessage
error={errorMessage}
data-testid="stripe-payment-error-message"
/>
</>
)
}
const ManualTestPaymentButton = ({ notReady }: { notReady: boolean }) => {
const [submitting, setSubmitting] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const onPaymentCompleted = async () => {
await placeOrder()
.catch((err) => {
setErrorMessage(err.message)
})
.finally(() => {
setSubmitting(false)
})
}
const handlePayment = () => {
setSubmitting(true)
onPaymentCompleted()
}
return (
<>
<Button
disabled={notReady}
isLoading={submitting}
onClick={handlePayment}
size="large"
data-testid="submit-order-button"
>
Place order
</Button>
<ErrorMessage
error={errorMessage}
data-testid="manual-payment-error-message"
/>
</>
)
}
export default PaymentButton

View File

@@ -0,0 +1,129 @@
import { Radio as RadioGroupOption } from "@headlessui/react"
import { Text, clx } from "@medusajs/ui"
import React, { useContext, useMemo, type JSX } from "react"
import Radio from "@modules/common/components/radio"
import { isManual } from "@lib/constants"
import SkeletonCardDetails from "@modules/skeletons/components/skeleton-card-details"
import { CardElement } from "@stripe/react-stripe-js"
import { StripeCardElementOptions } from "@stripe/stripe-js"
import PaymentTest from "../payment-test"
import { StripeContext } from "../payment-wrapper/stripe-wrapper"
type PaymentContainerProps = {
paymentProviderId: string
selectedPaymentOptionId: string | null
disabled?: boolean
paymentInfoMap: Record<string, { title: string; icon: JSX.Element }>
children?: React.ReactNode
}
const PaymentContainer: React.FC<PaymentContainerProps> = ({
paymentProviderId,
selectedPaymentOptionId,
paymentInfoMap,
disabled = false,
children,
}) => {
const isDevelopment = process.env.NODE_ENV === "development"
return (
<RadioGroupOption
key={paymentProviderId}
value={paymentProviderId}
disabled={disabled}
className={clx(
"flex flex-col gap-y-2 text-small-regular cursor-pointer py-4 border rounded-rounded px-8 mb-2 hover:shadow-borders-interactive-with-active",
{
"border-ui-border-interactive":
selectedPaymentOptionId === paymentProviderId,
}
)}
>
<div className="flex items-center justify-between ">
<div className="flex items-center gap-x-4">
<Radio checked={selectedPaymentOptionId === paymentProviderId} />
<Text className="text-base-regular">
{paymentInfoMap[paymentProviderId]?.title || paymentProviderId}
</Text>
{isManual(paymentProviderId) && isDevelopment && (
<PaymentTest className="hidden small:block" />
)}
</div>
<span className="justify-self-end text-ui-fg-base">
{paymentInfoMap[paymentProviderId]?.icon}
</span>
</div>
{isManual(paymentProviderId) && isDevelopment && (
<PaymentTest className="small:hidden text-[10px]" />
)}
{children}
</RadioGroupOption>
)
}
export default PaymentContainer
export const StripeCardContainer = ({
paymentProviderId,
selectedPaymentOptionId,
paymentInfoMap,
disabled = false,
setCardBrand,
setError,
setCardComplete,
}: Omit<PaymentContainerProps, "children"> & {
setCardBrand: (brand: string) => void
setError: (error: string | null) => void
setCardComplete: (complete: boolean) => void
}) => {
const stripeReady = useContext(StripeContext)
const useOptions: StripeCardElementOptions = useMemo(() => {
return {
style: {
base: {
fontFamily: "Inter, sans-serif",
color: "#424270",
"::placeholder": {
color: "rgb(107 114 128)",
},
},
},
classes: {
base: "pt-3 pb-1 block w-full h-11 px-4 mt-0 bg-ui-bg-field border rounded-md appearance-none focus:outline-none focus:ring-0 focus:shadow-borders-interactive-with-active border-ui-border-base hover:bg-ui-bg-field-hover transition-all duration-300 ease-in-out",
},
}
}, [])
return (
<PaymentContainer
paymentProviderId={paymentProviderId}
selectedPaymentOptionId={selectedPaymentOptionId}
paymentInfoMap={paymentInfoMap}
disabled={disabled}
>
{selectedPaymentOptionId === paymentProviderId &&
(stripeReady ? (
<div className="my-4 transition-all duration-150 ease-in-out">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Enter your card details:
</Text>
<CardElement
options={useOptions as StripeCardElementOptions}
onChange={(e) => {
setCardBrand(
e.brand && e.brand.charAt(0).toUpperCase() + e.brand.slice(1)
)
setError(e.error?.message || null)
setCardComplete(e.complete)
}}
/>
</div>
) : (
<SkeletonCardDetails />
))}
</PaymentContainer>
)
}

View File

@@ -0,0 +1,12 @@
import { Badge } from "@medusajs/ui"
const PaymentTest = ({ className }: { className?: string }) => {
return (
<Badge color="orange" className={className}>
<span className="font-semibold">Attention:</span> For testing purposes
only.
</Badge>
)
}
export default PaymentTest

View File

@@ -0,0 +1,41 @@
"use client"
import { loadStripe } from "@stripe/stripe-js"
import React from "react"
import StripeWrapper from "./stripe-wrapper"
import { HttpTypes } from "@medusajs/types"
import { isStripe } from "@lib/constants"
type PaymentWrapperProps = {
cart: HttpTypes.StoreCart
children: React.ReactNode
}
const stripeKey = process.env.NEXT_PUBLIC_STRIPE_KEY
const stripePromise = stripeKey ? loadStripe(stripeKey) : null
const PaymentWrapper: React.FC<PaymentWrapperProps> = ({ cart, children }) => {
const paymentSession = cart.payment_collection?.payment_sessions?.find(
(s) => s.status === "pending"
)
if (
isStripe(paymentSession?.provider_id) &&
paymentSession &&
stripePromise
) {
return (
<StripeWrapper
paymentSession={paymentSession}
stripeKey={stripeKey}
stripePromise={stripePromise}
>
{children}
</StripeWrapper>
)
}
return <div>{children}</div>
}
export default PaymentWrapper

View File

@@ -0,0 +1,54 @@
"use client"
import { Stripe, StripeElementsOptions } from "@stripe/stripe-js"
import { Elements } from "@stripe/react-stripe-js"
import { HttpTypes } from "@medusajs/types"
import { createContext } from "react"
type StripeWrapperProps = {
paymentSession: HttpTypes.StorePaymentSession
stripeKey?: string
stripePromise: Promise<Stripe | null> | null
children: React.ReactNode
}
export const StripeContext = createContext(false)
const StripeWrapper: React.FC<StripeWrapperProps> = ({
paymentSession,
stripeKey,
stripePromise,
children,
}) => {
const options: StripeElementsOptions = {
clientSecret: paymentSession!.data?.client_secret as string | undefined,
}
if (!stripeKey) {
throw new Error(
"Stripe key is missing. Set NEXT_PUBLIC_STRIPE_KEY environment variable."
)
}
if (!stripePromise) {
throw new Error(
"Stripe promise is missing. Make sure you have provided a valid Stripe key."
)
}
if (!paymentSession?.data?.client_secret) {
throw new Error(
"Stripe client secret is missing. Cannot initialize Stripe."
)
}
return (
<StripeContext.Provider value={true}>
<Elements options={options} stripe={stripePromise}>
{children}
</Elements>
</StripeContext.Provider>
)
}
export default StripeWrapper

View File

@@ -0,0 +1,261 @@
"use client"
import { RadioGroup } from "@headlessui/react"
import { isStripe as isStripeFunc, paymentInfoMap } from "@lib/constants"
import { initiatePaymentSession } from "@lib/data/cart"
import { CheckCircleSolid, CreditCard } from "@medusajs/icons"
import { Button, Container, Heading, Text, clx } from "@medusajs/ui"
import ErrorMessage from "@modules/checkout/components/error-message"
import PaymentContainer, {
StripeCardContainer,
} from "@modules/checkout/components/payment-container"
import Divider from "@modules/common/components/divider"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
const Payment = ({
cart,
availablePaymentMethods,
}: {
cart: any
availablePaymentMethods: any[]
}) => {
const activeSession = cart.payment_collection?.payment_sessions?.find(
(paymentSession: any) => paymentSession.status === "pending"
)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [cardBrand, setCardBrand] = useState<string | null>(null)
const [cardComplete, setCardComplete] = useState(false)
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(
activeSession?.provider_id ?? ""
)
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const isOpen = searchParams.get("step") === "payment"
const isStripe = isStripeFunc(selectedPaymentMethod)
const setPaymentMethod = async (method: string) => {
setError(null)
setSelectedPaymentMethod(method)
if (isStripeFunc(method)) {
await initiatePaymentSession(cart, {
provider_id: method,
})
}
}
const paidByGiftcard =
cart?.gift_cards && cart?.gift_cards?.length > 0 && cart?.total === 0
const paymentReady =
(activeSession && cart?.shipping_methods.length !== 0) || paidByGiftcard
const createQueryString = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams)
params.set(name, value)
return params.toString()
},
[searchParams]
)
const handleEdit = () => {
router.push(pathname + "?" + createQueryString("step", "payment"), {
scroll: false,
})
}
const handleSubmit = async () => {
setIsLoading(true)
try {
const shouldInputCard =
isStripeFunc(selectedPaymentMethod) && !activeSession
const checkActiveSession =
activeSession?.provider_id === selectedPaymentMethod
if (!checkActiveSession) {
await initiatePaymentSession(cart, {
provider_id: selectedPaymentMethod,
})
}
if (!shouldInputCard) {
return router.push(
pathname + "?" + createQueryString("step", "review"),
{
scroll: false,
}
)
}
} catch (err: any) {
setError(err.message)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
setError(null)
}, [isOpen])
return (
<div className="bg-white">
<div className="flex flex-row items-center justify-between mb-6">
<Heading
level="h2"
className={clx(
"flex flex-row text-3xl-regular gap-x-2 items-baseline",
{
"opacity-50 pointer-events-none select-none":
!isOpen && !paymentReady,
}
)}
>
Payment
{!isOpen && paymentReady && <CheckCircleSolid />}
</Heading>
{!isOpen && paymentReady && (
<Text>
<button
onClick={handleEdit}
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
data-testid="edit-payment-button"
>
Edit
</button>
</Text>
)}
</div>
<div>
<div className={isOpen ? "block" : "hidden"}>
{!paidByGiftcard && availablePaymentMethods?.length && (
<>
<RadioGroup
value={selectedPaymentMethod}
onChange={(value: string) => setPaymentMethod(value)}
>
{availablePaymentMethods.map((paymentMethod) => (
<div key={paymentMethod.id}>
{isStripeFunc(paymentMethod.id) ? (
<StripeCardContainer
paymentProviderId={paymentMethod.id}
selectedPaymentOptionId={selectedPaymentMethod}
paymentInfoMap={paymentInfoMap}
setCardBrand={setCardBrand}
setError={setError}
setCardComplete={setCardComplete}
/>
) : (
<PaymentContainer
paymentInfoMap={paymentInfoMap}
paymentProviderId={paymentMethod.id}
selectedPaymentOptionId={selectedPaymentMethod}
/>
)}
</div>
))}
</RadioGroup>
</>
)}
{paidByGiftcard && (
<div className="flex flex-col w-1/3">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Payment method
</Text>
<Text
className="txt-medium text-ui-fg-subtle"
data-testid="payment-method-summary"
>
Gift card
</Text>
</div>
)}
<ErrorMessage
error={error}
data-testid="payment-method-error-message"
/>
<Button
size="large"
className="mt-6"
onClick={handleSubmit}
isLoading={isLoading}
disabled={
(isStripe && !cardComplete) ||
(!selectedPaymentMethod && !paidByGiftcard)
}
data-testid="submit-payment-button"
>
{!activeSession && isStripeFunc(selectedPaymentMethod)
? " Enter card details"
: "Continue to review"}
</Button>
</div>
<div className={isOpen ? "hidden" : "block"}>
{cart && paymentReady && activeSession ? (
<div className="flex items-start gap-x-1 w-full">
<div className="flex flex-col w-1/3">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Payment method
</Text>
<Text
className="txt-medium text-ui-fg-subtle"
data-testid="payment-method-summary"
>
{paymentInfoMap[activeSession?.provider_id]?.title ||
activeSession?.provider_id}
</Text>
</div>
<div className="flex flex-col w-1/3">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Payment details
</Text>
<div
className="flex gap-2 txt-medium text-ui-fg-subtle items-center"
data-testid="payment-details-summary"
>
<Container className="flex items-center h-7 w-fit p-2 bg-ui-button-neutral-hover">
{paymentInfoMap[selectedPaymentMethod]?.icon || (
<CreditCard />
)}
</Container>
<Text>
{isStripeFunc(selectedPaymentMethod) && cardBrand
? cardBrand
: "Another step will appear"}
</Text>
</div>
</div>
</div>
) : paidByGiftcard ? (
<div className="flex flex-col w-1/3">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Payment method
</Text>
<Text
className="txt-medium text-ui-fg-subtle"
data-testid="payment-method-summary"
>
Gift card
</Text>
</div>
) : null}
</div>
</div>
<Divider className="mt-8" />
</div>
)
}
export default Payment

View File

@@ -0,0 +1,55 @@
"use client"
import { Heading, Text, clx } from "@medusajs/ui"
import PaymentButton from "../payment-button"
import { useSearchParams } from "next/navigation"
const Review = ({ cart }: { cart: any }) => {
const searchParams = useSearchParams()
const isOpen = searchParams.get("step") === "review"
const paidByGiftcard =
cart?.gift_cards && cart?.gift_cards?.length > 0 && cart?.total === 0
const previousStepsCompleted =
cart.shipping_address &&
cart.shipping_methods.length > 0 &&
(cart.payment_collection || paidByGiftcard)
return (
<div className="bg-white">
<div className="flex flex-row items-center justify-between mb-6">
<Heading
level="h2"
className={clx(
"flex flex-row text-3xl-regular gap-x-2 items-baseline",
{
"opacity-50 pointer-events-none select-none": !isOpen,
}
)}
>
Review
</Heading>
</div>
{isOpen && previousStepsCompleted && (
<>
<div className="flex items-start gap-x-1 w-full mb-6">
<div className="w-full">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
By clicking the Place Order button, you confirm that you have
read, understand and accept our Terms of Use, Terms of Sale and
Returns Policy and acknowledge that you have read Medusa
Store&apos;s Privacy Policy.
</Text>
</div>
</div>
<PaymentButton cart={cart} data-testid="submit-order-button" />
</>
)}
</div>
)
}
export default Review

View File

@@ -0,0 +1,219 @@
import { HttpTypes } from "@medusajs/types"
import { Container } from "@medusajs/ui"
import Checkbox from "@modules/common/components/checkbox"
import Input from "@modules/common/components/input"
import { mapKeys } from "lodash"
import React, { useEffect, useMemo, useState } from "react"
import AddressSelect from "../address-select"
import CountrySelect from "../country-select"
const ShippingAddress = ({
customer,
cart,
checked,
onChange,
}: {
customer: HttpTypes.StoreCustomer | null
cart: HttpTypes.StoreCart | null
checked: boolean
onChange: () => void
}) => {
const [formData, setFormData] = useState<Record<string, any>>({
"shipping_address.first_name": cart?.shipping_address?.first_name || "",
"shipping_address.last_name": cart?.shipping_address?.last_name || "",
"shipping_address.address_1": cart?.shipping_address?.address_1 || "",
"shipping_address.company": cart?.shipping_address?.company || "",
"shipping_address.postal_code": cart?.shipping_address?.postal_code || "",
"shipping_address.city": cart?.shipping_address?.city || "",
"shipping_address.country_code": cart?.shipping_address?.country_code || "",
"shipping_address.province": cart?.shipping_address?.province || "",
"shipping_address.phone": cart?.shipping_address?.phone || "",
email: cart?.email || "",
})
const countriesInRegion = useMemo(
() => cart?.region?.countries?.map((c) => c.iso_2),
[cart?.region]
)
// check if customer has saved addresses that are in the current region
const addressesInRegion = useMemo(
() =>
customer?.addresses.filter(
(a) => a.country_code && countriesInRegion?.includes(a.country_code)
),
[customer?.addresses, countriesInRegion]
)
const setFormAddress = (
address?: HttpTypes.StoreCartAddress,
email?: string
) => {
address &&
setFormData((prevState: Record<string, any>) => ({
...prevState,
"shipping_address.first_name": address?.first_name || "",
"shipping_address.last_name": address?.last_name || "",
"shipping_address.address_1": address?.address_1 || "",
"shipping_address.company": address?.company || "",
"shipping_address.postal_code": address?.postal_code || "",
"shipping_address.city": address?.city || "",
"shipping_address.country_code": address?.country_code || "",
"shipping_address.province": address?.province || "",
"shipping_address.phone": address?.phone || "",
}))
email &&
setFormData((prevState: Record<string, any>) => ({
...prevState,
email: email,
}))
}
useEffect(() => {
// Ensure cart is not null and has a shipping_address before setting form data
if (cart && cart.shipping_address) {
setFormAddress(cart?.shipping_address, cart?.email)
}
if (cart && !cart.email && customer?.email) {
setFormAddress(undefined, customer.email)
}
}, [cart]) // Add cart as a dependency
const handleChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLInputElement | HTMLSelectElement
>
) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
})
}
return (
<>
{customer && (addressesInRegion?.length || 0) > 0 && (
<Container className="mb-6 flex flex-col gap-y-4 p-5">
<p className="text-small-regular">
{`Hi ${customer.first_name}, do you want to use one of your saved addresses?`}
</p>
<AddressSelect
addresses={customer.addresses}
addressInput={
mapKeys(formData, (_, key) =>
key.replace("shipping_address.", "")
) as HttpTypes.StoreCartAddress
}
onSelect={setFormAddress}
/>
</Container>
)}
<div className="grid grid-cols-2 gap-4">
<Input
label="First name"
name="shipping_address.first_name"
autoComplete="given-name"
value={formData["shipping_address.first_name"]}
onChange={handleChange}
required
data-testid="shipping-first-name-input"
/>
<Input
label="Last name"
name="shipping_address.last_name"
autoComplete="family-name"
value={formData["shipping_address.last_name"]}
onChange={handleChange}
required
data-testid="shipping-last-name-input"
/>
<Input
label="Address"
name="shipping_address.address_1"
autoComplete="address-line1"
value={formData["shipping_address.address_1"]}
onChange={handleChange}
required
data-testid="shipping-address-input"
/>
<Input
label="Company"
name="shipping_address.company"
value={formData["shipping_address.company"]}
onChange={handleChange}
autoComplete="organization"
data-testid="shipping-company-input"
/>
<Input
label="Postal code"
name="shipping_address.postal_code"
autoComplete="postal-code"
value={formData["shipping_address.postal_code"]}
onChange={handleChange}
required
data-testid="shipping-postal-code-input"
/>
<Input
label="City"
name="shipping_address.city"
autoComplete="address-level2"
value={formData["shipping_address.city"]}
onChange={handleChange}
required
data-testid="shipping-city-input"
/>
<CountrySelect
name="shipping_address.country_code"
autoComplete="country"
region={cart?.region}
value={formData["shipping_address.country_code"]}
onChange={handleChange}
required
data-testid="shipping-country-select"
/>
<Input
label="State / Province"
name="shipping_address.province"
autoComplete="address-level1"
value={formData["shipping_address.province"]}
onChange={handleChange}
data-testid="shipping-province-input"
/>
</div>
<div className="my-8">
<Checkbox
label="Billing address same as shipping address"
name="same_as_billing"
checked={checked}
onChange={onChange}
data-testid="billing-address-checkbox"
/>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<Input
label="Email"
name="email"
type="email"
title="Enter a valid email address."
autoComplete="email"
value={formData.email}
onChange={handleChange}
required
data-testid="shipping-email-input"
/>
<Input
label="Phone"
name="shipping_address.phone"
autoComplete="tel"
value={formData["shipping_address.phone"]}
onChange={handleChange}
data-testid="shipping-phone-input"
/>
</div>
</>
)
}
export default ShippingAddress

View File

@@ -0,0 +1,400 @@
"use client"
import { RadioGroup, Radio } from "@headlessui/react"
import { setShippingMethod } from "@lib/data/cart"
import { calculatePriceForShippingOption } from "@lib/data/fulfillment"
import { convertToLocale } from "@lib/util/money"
import { CheckCircleSolid, Loader } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Button, Heading, Text, clx } from "@medusajs/ui"
import ErrorMessage from "@modules/checkout/components/error-message"
import Divider from "@modules/common/components/divider"
import MedusaRadio from "@modules/common/components/radio"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
const PICKUP_OPTION_ON = "__PICKUP_ON"
const PICKUP_OPTION_OFF = "__PICKUP_OFF"
type ShippingProps = {
cart: HttpTypes.StoreCart
availableShippingMethods: HttpTypes.StoreCartShippingOption[] | null
}
function formatAddress(address) {
if (!address) {
return ""
}
let ret = ""
if (address.address_1) {
ret += ` ${address.address_1}`
}
if (address.address_2) {
ret += `, ${address.address_2}`
}
if (address.postal_code) {
ret += `, ${address.postal_code} ${address.city}`
}
if (address.country_code) {
ret += `, ${address.country_code.toUpperCase()}`
}
return ret
}
const Shipping: React.FC<ShippingProps> = ({
cart,
availableShippingMethods,
}) => {
const [isLoading, setIsLoading] = useState(false)
const [isLoadingPrices, setIsLoadingPrices] = useState(true)
const [showPickupOptions, setShowPickupOptions] =
useState<string>(PICKUP_OPTION_OFF)
const [calculatedPricesMap, setCalculatedPricesMap] = useState<
Record<string, number>
>({})
const [error, setError] = useState<string | null>(null)
const [shippingMethodId, setShippingMethodId] = useState<string | null>(
cart.shipping_methods?.at(-1)?.shipping_option_id || null
)
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const isOpen = searchParams.get("step") === "delivery"
const _shippingMethods = availableShippingMethods?.filter(
(sm) => sm.service_zone?.fulfillment_set?.type !== "pickup"
)
const _pickupMethods = availableShippingMethods?.filter(
(sm) => sm.service_zone?.fulfillment_set?.type === "pickup"
)
const hasPickupOptions = !!_pickupMethods?.length
useEffect(() => {
setIsLoadingPrices(true)
if (_shippingMethods?.length) {
const promises = _shippingMethods
.filter((sm) => sm.price_type === "calculated")
.map((sm) => calculatePriceForShippingOption(sm.id, cart.id))
if (promises.length) {
Promise.allSettled(promises).then((res) => {
const pricesMap: Record<string, number> = {}
res
.filter((r) => r.status === "fulfilled")
.forEach((p) => (pricesMap[p.value?.id || ""] = p.value?.amount!))
setCalculatedPricesMap(pricesMap)
setIsLoadingPrices(false)
})
}
}
if (_pickupMethods?.find((m) => m.id === shippingMethodId)) {
setShowPickupOptions(PICKUP_OPTION_ON)
}
}, [availableShippingMethods])
const handleEdit = () => {
router.push(pathname + "?step=delivery", { scroll: false })
}
const handleSubmit = () => {
router.push(pathname + "?step=payment", { scroll: false })
}
const handleSetShippingMethod = async (
id: string,
variant: "shipping" | "pickup"
) => {
setError(null)
if (variant === "pickup") {
setShowPickupOptions(PICKUP_OPTION_ON)
} else {
setShowPickupOptions(PICKUP_OPTION_OFF)
}
let currentId: string | null = null
setIsLoading(true)
setShippingMethodId((prev) => {
currentId = prev
return id
})
await setShippingMethod({ cartId: cart.id, shippingMethodId: id })
.catch((err) => {
setShippingMethodId(currentId)
setError(err.message)
})
.finally(() => {
setIsLoading(false)
})
}
useEffect(() => {
setError(null)
}, [isOpen])
return (
<div className="bg-white">
<div className="flex flex-row items-center justify-between mb-6">
<Heading
level="h2"
className={clx(
"flex flex-row text-3xl-regular gap-x-2 items-baseline",
{
"opacity-50 pointer-events-none select-none":
!isOpen && cart.shipping_methods?.length === 0,
}
)}
>
Delivery
{!isOpen && (cart.shipping_methods?.length ?? 0) > 0 && (
<CheckCircleSolid />
)}
</Heading>
{!isOpen &&
cart?.shipping_address &&
cart?.billing_address &&
cart?.email && (
<Text>
<button
onClick={handleEdit}
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
data-testid="edit-delivery-button"
>
Edit
</button>
</Text>
)}
</div>
{isOpen ? (
<>
<div className="grid">
<div className="flex flex-col">
<span className="font-medium txt-medium text-ui-fg-base">
Shipping method
</span>
<span className="mb-4 text-ui-fg-muted txt-medium">
How would you like you order delivered
</span>
</div>
<div data-testid="delivery-options-container">
<div className="pb-8 md:pt-0 pt-2">
{hasPickupOptions && (
<RadioGroup
value={showPickupOptions}
onChange={(value) => {
const id = _pickupMethods.find(
(option) => !option.insufficient_inventory
)?.id
if (id) {
handleSetShippingMethod(id, "pickup")
}
}}
>
<Radio
value={PICKUP_OPTION_ON}
data-testid="delivery-option-radio"
className={clx(
"flex items-center justify-between text-small-regular cursor-pointer py-4 border rounded-rounded px-8 mb-2 hover:shadow-borders-interactive-with-active",
{
"border-ui-border-interactive":
showPickupOptions === PICKUP_OPTION_ON,
}
)}
>
<div className="flex items-center gap-x-4">
<MedusaRadio
checked={showPickupOptions === PICKUP_OPTION_ON}
/>
<span className="text-base-regular">
Pick up your order
</span>
</div>
<span className="justify-self-end text-ui-fg-base">
-
</span>
</Radio>
</RadioGroup>
)}
<RadioGroup
value={shippingMethodId}
onChange={(v) => handleSetShippingMethod(v, "shipping")}
>
{_shippingMethods?.map((option) => {
const isDisabled =
option.price_type === "calculated" &&
!isLoadingPrices &&
typeof calculatedPricesMap[option.id] !== "number"
return (
<Radio
key={option.id}
value={option.id}
data-testid="delivery-option-radio"
disabled={isDisabled}
className={clx(
"flex items-center justify-between text-small-regular cursor-pointer py-4 border rounded-rounded px-8 mb-2 hover:shadow-borders-interactive-with-active",
{
"border-ui-border-interactive":
option.id === shippingMethodId,
"hover:shadow-brders-none cursor-not-allowed":
isDisabled,
}
)}
>
<div className="flex items-center gap-x-4">
<MedusaRadio
checked={option.id === shippingMethodId}
/>
<span className="text-base-regular">
{option.name}
</span>
</div>
<span className="justify-self-end text-ui-fg-base">
{option.price_type === "flat" ? (
convertToLocale({
amount: option.amount!,
currency_code: cart?.currency_code,
})
) : calculatedPricesMap[option.id] ? (
convertToLocale({
amount: calculatedPricesMap[option.id],
currency_code: cart?.currency_code,
})
) : isLoadingPrices ? (
<Loader />
) : (
"-"
)}
</span>
</Radio>
)
})}
</RadioGroup>
</div>
</div>
</div>
{showPickupOptions === PICKUP_OPTION_ON && (
<div className="grid">
<div className="flex flex-col">
<span className="font-medium txt-medium text-ui-fg-base">
Store
</span>
<span className="mb-4 text-ui-fg-muted txt-medium">
Choose a store near you
</span>
</div>
<div data-testid="delivery-options-container">
<div className="pb-8 md:pt-0 pt-2">
<RadioGroup
value={shippingMethodId}
onChange={(v) => handleSetShippingMethod(v, "pickup")}
>
{_pickupMethods?.map((option) => {
return (
<Radio
key={option.id}
value={option.id}
disabled={option.insufficient_inventory}
data-testid="delivery-option-radio"
className={clx(
"flex items-center justify-between text-small-regular cursor-pointer py-4 border rounded-rounded px-8 mb-2 hover:shadow-borders-interactive-with-active",
{
"border-ui-border-interactive":
option.id === shippingMethodId,
"hover:shadow-brders-none cursor-not-allowed":
option.insufficient_inventory,
}
)}
>
<div className="flex items-start gap-x-4">
<MedusaRadio
checked={option.id === shippingMethodId}
/>
<div className="flex flex-col">
<span className="text-base-regular">
{option.name}
</span>
<span className="text-base-regular text-ui-fg-muted">
{formatAddress(
option.service_zone?.fulfillment_set?.location
?.address
)}
</span>
</div>
</div>
<span className="justify-self-end text-ui-fg-base">
{convertToLocale({
amount: option.amount!,
currency_code: cart?.currency_code,
})}
</span>
</Radio>
)
})}
</RadioGroup>
</div>
</div>
</div>
)}
<div>
<ErrorMessage
error={error}
data-testid="delivery-option-error-message"
/>
<Button
size="large"
className="mt"
onClick={handleSubmit}
isLoading={isLoading}
disabled={!cart.shipping_methods?.[0]}
data-testid="submit-delivery-option-button"
>
Continue to payment
</Button>
</div>
</>
) : (
<div>
<div className="text-small-regular">
{cart && (cart.shipping_methods?.length ?? 0) > 0 && (
<div className="flex flex-col w-1/3">
<Text className="txt-medium-plus text-ui-fg-base mb-1">
Method
</Text>
<Text className="txt-medium text-ui-fg-subtle">
{cart.shipping_methods?.at(-1)?.name}{" "}
{convertToLocale({
amount: cart.shipping_methods.at(-1)?.amount!,
currency_code: cart?.currency_code,
})}
</Text>
</div>
)}
</div>
</div>
)}
<Divider className="mt-8" />
</div>
)
}
export default Shipping

View File

@@ -0,0 +1,32 @@
"use client"
import { Button } from "@medusajs/ui"
import React from "react"
import { useFormStatus } from "react-dom"
export function SubmitButton({
children,
variant = "primary",
className,
"data-testid": dataTestId,
}: {
children: React.ReactNode
variant?: "primary" | "secondary" | "transparent" | "danger" | null
className?: string
"data-testid"?: string
}) {
const { pending } = useFormStatus()
return (
<Button
size="large"
className={className}
type="submit"
isLoading={pending}
variant={variant || "primary"}
data-testid={dataTestId}
>
{children}
</Button>
)
}

View File

@@ -0,0 +1,38 @@
import { listCartShippingMethods } from "@lib/data/fulfillment"
import { listCartPaymentMethods } from "@lib/data/payment"
import { HttpTypes } from "@medusajs/types"
import Addresses from "@modules/checkout/components/addresses"
import Payment from "@modules/checkout/components/payment"
import Review from "@modules/checkout/components/review"
import Shipping from "@modules/checkout/components/shipping"
export default async function CheckoutForm({
cart,
customer,
}: {
cart: HttpTypes.StoreCart | null
customer: HttpTypes.StoreCustomer | null
}) {
if (!cart) {
return null
}
const shippingMethods = await listCartShippingMethods(cart.id)
const paymentMethods = await listCartPaymentMethods(cart.region?.id ?? "")
if (!shippingMethods || !paymentMethods) {
return null
}
return (
<div className="w-full grid grid-cols-1 gap-y-8">
<Addresses cart={cart} customer={customer} />
<Shipping cart={cart} availableShippingMethods={shippingMethods} />
<Payment cart={cart} availablePaymentMethods={paymentMethods} />
<Review cart={cart} />
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { Heading } from "@medusajs/ui"
import ItemsPreviewTemplate from "@modules/cart/templates/preview"
import DiscountCode from "@modules/checkout/components/discount-code"
import CartTotals from "@modules/common/components/cart-totals"
import Divider from "@modules/common/components/divider"
const CheckoutSummary = ({ cart }: { cart: any }) => {
return (
<div className="sticky top-0 flex flex-col-reverse small:flex-col gap-y-8 py-8 small:py-0 ">
<div className="w-full bg-white flex flex-col">
<Divider className="my-6 small:hidden" />
<Heading
level="h2"
className="flex flex-row text-3xl-regular items-baseline"
>
In your Cart
</Heading>
<Divider className="my-6" />
<CartTotals totals={cart} />
<ItemsPreviewTemplate cart={cart} />
<div className="my-6">
<DiscountCode cart={cart} />
</div>
</div>
</div>
)
}
export default CheckoutSummary

View File

@@ -0,0 +1,47 @@
import { Suspense } from "react"
import SkeletonProductGrid from "@modules/skeletons/templates/skeleton-product-grid"
import RefinementList from "@modules/store/components/refinement-list"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
import PaginatedProducts from "@modules/store/templates/paginated-products"
import { HttpTypes } from "@medusajs/types"
export default function CollectionTemplate({
sortBy,
collection,
page,
countryCode,
}: {
sortBy?: SortOptions
collection: HttpTypes.StoreCollection
page?: string
countryCode: string
}) {
const pageNumber = page ? parseInt(page) : 1
const sort = sortBy || "created_at"
return (
<div className="flex flex-col small:flex-row small:items-start py-6 content-container">
<RefinementList sortBy={sort} />
<div className="w-full">
<div className="mb-8 text-2xl-semi">
<h1>{collection.title}</h1>
</div>
<Suspense
fallback={
<SkeletonProductGrid
numberOfProducts={collection.products?.length}
/>
}
>
<PaginatedProducts
sortBy={sort}
page={pageNumber}
collectionId={collection.id}
countryCode={countryCode}
/>
</Suspense>
</div>
</div>
)
}

View File

@@ -0,0 +1,96 @@
"use client"
import { convertToLocale } from "@lib/util/money"
import React from "react"
type CartTotalsProps = {
totals: {
total?: number | null
subtotal?: number | null
tax_total?: number | null
shipping_total?: number | null
discount_total?: number | null
gift_card_total?: number | null
currency_code: string
shipping_subtotal?: number | null
}
}
const CartTotals: React.FC<CartTotalsProps> = ({ totals }) => {
const {
currency_code,
total,
subtotal,
tax_total,
discount_total,
gift_card_total,
shipping_subtotal,
} = totals
return (
<div>
<div className="flex flex-col gap-y-2 txt-medium text-ui-fg-subtle ">
<div className="flex items-center justify-between">
<span className="flex gap-x-1 items-center">
Subtotal (excl. shipping and taxes)
</span>
<span data-testid="cart-subtotal" data-value={subtotal || 0}>
{convertToLocale({ amount: subtotal ?? 0, currency_code })}
</span>
</div>
{!!discount_total && (
<div className="flex items-center justify-between">
<span>Discount</span>
<span
className="text-ui-fg-interactive"
data-testid="cart-discount"
data-value={discount_total || 0}
>
-{" "}
{convertToLocale({ amount: discount_total ?? 0, currency_code })}
</span>
</div>
)}
<div className="flex items-center justify-between">
<span>Shipping</span>
<span data-testid="cart-shipping" data-value={shipping_subtotal || 0}>
{convertToLocale({ amount: shipping_subtotal ?? 0, currency_code })}
</span>
</div>
<div className="flex justify-between">
<span className="flex gap-x-1 items-center ">Taxes</span>
<span data-testid="cart-taxes" data-value={tax_total || 0}>
{convertToLocale({ amount: tax_total ?? 0, currency_code })}
</span>
</div>
{!!gift_card_total && (
<div className="flex items-center justify-between">
<span>Gift card</span>
<span
className="text-ui-fg-interactive"
data-testid="cart-gift-card-amount"
data-value={gift_card_total || 0}
>
-{" "}
{convertToLocale({ amount: gift_card_total ?? 0, currency_code })}
</span>
</div>
)}
</div>
<div className="h-px w-full border-b border-gray-200 my-4" />
<div className="flex items-center justify-between text-ui-fg-base mb-2 txt-medium ">
<span>Total</span>
<span
className="txt-xlarge-plus"
data-testid="cart-total"
data-value={total || 0}
>
{convertToLocale({ amount: total ?? 0, currency_code })}
</span>
</div>
<div className="h-px w-full border-b border-gray-200 mt-4" />
</div>
)
}
export default CartTotals

View File

@@ -0,0 +1,43 @@
import { Checkbox, Label } from "@medusajs/ui"
import React from "react"
type CheckboxProps = {
checked?: boolean
onChange?: () => void
label: string
name?: string
'data-testid'?: string
}
const CheckboxWithLabel: React.FC<CheckboxProps> = ({
checked = true,
onChange,
label,
name,
'data-testid': dataTestId
}) => {
return (
<div className="flex items-center space-x-2 ">
<Checkbox
className="text-base-regular flex items-center gap-x-2"
id="checkbox"
role="checkbox"
type="button"
checked={checked}
aria-checked={checked}
onClick={onChange}
name={name}
data-testid={dataTestId}
/>
<Label
htmlFor="checkbox"
className="!transform-none !txt-medium"
size="large"
>
{label}
</Label>
</div>
)
}
export default CheckboxWithLabel

View File

@@ -0,0 +1,42 @@
import { deleteLineItem } from "@lib/data/cart"
import { Spinner, Trash } from "@medusajs/icons"
import { clx } from "@medusajs/ui"
import { useState } from "react"
const DeleteButton = ({
id,
children,
className,
}: {
id: string
children?: React.ReactNode
className?: string
}) => {
const [isDeleting, setIsDeleting] = useState(false)
const handleDelete = async (id: string) => {
setIsDeleting(true)
await deleteLineItem(id).catch((err) => {
setIsDeleting(false)
})
}
return (
<div
className={clx(
"flex items-center justify-between text-small-regular",
className
)}
>
<button
className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer"
onClick={() => handleDelete(id)}
>
{isDeleting ? <Spinner className="animate-spin" /> : <Trash />}
<span>{children}</span>
</button>
</div>
)
}
export default DeleteButton

View File

@@ -0,0 +1,9 @@
import { clx } from "@medusajs/ui"
const Divider = ({ className }: { className?: string }) => (
<div
className={clx("h-px w-full border-b border-gray-200 mt-1", className)}
/>
)
export default Divider

View File

@@ -0,0 +1,60 @@
import { EllipseMiniSolid } from "@medusajs/icons"
import { Label, RadioGroup, Text, clx } from "@medusajs/ui"
type FilterRadioGroupProps = {
title: string
items: {
value: string
label: string
}[]
value: any
handleChange: (...args: any[]) => void
"data-testid"?: string
}
const FilterRadioGroup = ({
title,
items,
value,
handleChange,
"data-testid": dataTestId,
}: FilterRadioGroupProps) => {
return (
<div className="flex gap-x-3 flex-col gap-y-3">
<Text className="txt-compact-small-plus text-ui-fg-muted">{title}</Text>
<RadioGroup data-testid={dataTestId} onValueChange={handleChange}>
{items?.map((i) => (
<div
key={i.value}
className={clx("flex gap-x-2 items-center", {
"ml-[-23px]": i.value === value,
})}
>
{i.value === value && <EllipseMiniSolid />}
<RadioGroup.Item
checked={i.value === value}
className="hidden peer"
id={i.value}
value={i.value}
/>
<Label
htmlFor={i.value}
className={clx(
"!txt-compact-small !transform-none text-ui-fg-subtle hover:cursor-pointer",
{
"text-ui-fg-base": i.value === value,
}
)}
data-testid="radio-label"
data-active={i.value === value}
>
{i.label}
</Label>
</div>
))}
</RadioGroup>
</div>
)
}
export default FilterRadioGroup

View File

@@ -0,0 +1,55 @@
// cart-totals
export { default as CartTotals } from "./cart-totals";
export * from "./cart-totals";
// checkbox
export { default as Checkbox } from "./checkbox";
export * from "./checkbox";
// delete-button
export { default as DeleteButton } from "./delete-button";
export * from "./delete-button";
// divider
export { default as Divider } from "./divider";
export * from "./divider";
// filter-radio-group
export { default as FilterRadioGroup } from "./filter-radio-group";
export * from "./filter-radio-group";
// input
export { default as Input } from "./input";
export * from "./input";
// interactive-link
export { default as InteractiveLink } from "./interactive-link";
export * from "./interactive-link";
// line-item-options
export { default as LineItemOptions } from "./line-item-options";
export * from "./line-item-options";
// line-item-price
export { default as LineItemPrice } from "./line-item-price";
export * from "./line-item-price";
// line-item-unit-price
export { default as LineItemUnitPrice } from "./line-item-unit-price";
export * from "./line-item-unit-price";
// localized-client-link
export { default as LocalizedClientLink } from "./localized-client-link";
export * from "./localized-client-link";
// modal
export { default as Modal } from "./modal";
export * from "./modal";
// native-select
export { default as NativeSelect } from "./native-select";
export * from "./native-select";
// radio
export { default as Radio } from "./radio";
export * from "./radio";

View File

@@ -0,0 +1,76 @@
import { Label } from "@medusajs/ui"
import React, { useEffect, useImperativeHandle, useState } from "react"
import Eye from "@modules/common/icons/eye"
import EyeOff from "@modules/common/icons/eye-off"
type InputProps = Omit<
Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
"placeholder"
> & {
label: string
errors?: Record<string, unknown>
touched?: Record<string, unknown>
name: string
topLabel?: string
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ type, name, label, touched, required, topLabel, ...props }, ref) => {
const inputRef = React.useRef<HTMLInputElement>(null)
const [showPassword, setShowPassword] = useState(false)
const [inputType, setInputType] = useState(type)
useEffect(() => {
if (type === "password" && showPassword) {
setInputType("text")
}
if (type === "password" && !showPassword) {
setInputType("password")
}
}, [type, showPassword])
useImperativeHandle(ref, () => inputRef.current!)
return (
<div className="flex flex-col w-full">
{topLabel && (
<Label className="mb-2 txt-compact-medium-plus">{topLabel}</Label>
)}
<div className="flex relative z-0 w-full txt-compact-medium">
<input
type={inputType}
name={name}
placeholder=" "
required={required}
className="pt-4 pb-1 block w-full h-11 px-4 mt-0 bg-ui-bg-field border rounded-md appearance-none focus:outline-none focus:ring-0 focus:shadow-borders-interactive-with-active border-ui-border-base hover:bg-ui-bg-field-hover"
{...props}
ref={inputRef}
/>
<label
htmlFor={name}
onClick={() => inputRef.current?.focus()}
className="flex items-center justify-center mx-3 px-1 transition-all absolute duration-300 top-3 -z-1 origin-0 text-ui-fg-subtle"
>
{label}
{required && <span className="text-rose-500">*</span>}
</label>
{type === "password" && (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-ui-fg-subtle px-4 focus:outline-none transition-all duration-150 outline-none focus:text-ui-fg-base absolute right-0 top-3"
>
{showPassword ? <Eye /> : <EyeOff />}
</button>
)}
</div>
</div>
)
}
)
Input.displayName = "Input"
export default Input

View File

@@ -0,0 +1,33 @@
import { ArrowUpRightMini } from "@medusajs/icons"
import { Text } from "@medusajs/ui"
import LocalizedClientLink from "../localized-client-link"
type InteractiveLinkProps = {
href: string
children?: React.ReactNode
onClick?: () => void
}
const InteractiveLink = ({
href,
children,
onClick,
...props
}: InteractiveLinkProps) => {
return (
<LocalizedClientLink
className="flex gap-x-1 items-center group"
href={href}
onClick={onClick}
{...props}
>
<Text className="text-ui-fg-interactive">{children}</Text>
<ArrowUpRightMini
className="group-hover:rotate-45 ease-in-out duration-150"
color="var(--fg-interactive)"
/>
</LocalizedClientLink>
)
}
export default InteractiveLink

View File

@@ -0,0 +1,26 @@
import { HttpTypes } from "@medusajs/types"
import { Text } from "@medusajs/ui"
type LineItemOptionsProps = {
variant: HttpTypes.StoreProductVariant | undefined
"data-testid"?: string
"data-value"?: HttpTypes.StoreProductVariant
}
const LineItemOptions = ({
variant,
"data-testid": dataTestid,
"data-value": dataValue,
}: LineItemOptionsProps) => {
return (
<Text
data-testid={dataTestid}
data-value={dataValue}
className="inline-block txt-medium text-ui-fg-subtle w-full overflow-hidden text-ellipsis"
>
Variant: {variant?.title}
</Text>
)
}
export default LineItemOptions

View File

@@ -0,0 +1,64 @@
import { getPercentageDiff } from "@lib/util/get-precentage-diff"
import { convertToLocale } from "@lib/util/money"
import { HttpTypes } from "@medusajs/types"
import { clx } from "@medusajs/ui"
type LineItemPriceProps = {
item: HttpTypes.StoreCartLineItem | HttpTypes.StoreOrderLineItem
style?: "default" | "tight"
currencyCode: string
}
const LineItemPrice = ({
item,
style = "default",
currencyCode,
}: LineItemPriceProps) => {
const { total, original_total } = item
const originalPrice = original_total
const currentPrice = total
const hasReducedPrice = currentPrice < originalPrice
return (
<div className="flex flex-col gap-x-2 text-ui-fg-subtle items-end">
<div className="text-left">
{hasReducedPrice && (
<>
<p>
{style === "default" && (
<span className="text-ui-fg-subtle">Original: </span>
)}
<span
className="line-through text-ui-fg-muted"
data-testid="product-original-price"
>
{convertToLocale({
amount: originalPrice,
currency_code: currencyCode,
})}
</span>
</p>
{style === "default" && (
<span className="text-ui-fg-interactive">
-{getPercentageDiff(originalPrice, currentPrice || 0)}%
</span>
)}
</>
)}
<span
className={clx("text-base-regular", {
"text-ui-fg-interactive": hasReducedPrice,
})}
data-testid="product-price"
>
{convertToLocale({
amount: currentPrice,
currency_code: currencyCode,
})}
</span>
</div>
</div>
)
}
export default LineItemPrice

View File

@@ -0,0 +1,61 @@
import { convertToLocale } from "@lib/util/money"
import { HttpTypes } from "@medusajs/types"
import { clx } from "@medusajs/ui"
type LineItemUnitPriceProps = {
item: HttpTypes.StoreCartLineItem | HttpTypes.StoreOrderLineItem
style?: "default" | "tight"
currencyCode: string
}
const LineItemUnitPrice = ({
item,
style = "default",
currencyCode,
}: LineItemUnitPriceProps) => {
const { total, original_total } = item
const hasReducedPrice = total < original_total
const percentage_diff = Math.round(
((original_total - total) / original_total) * 100
)
return (
<div className="flex flex-col text-ui-fg-muted justify-center h-full">
{hasReducedPrice && (
<>
<p>
{style === "default" && (
<span className="text-ui-fg-muted">Original: </span>
)}
<span
className="line-through"
data-testid="product-unit-original-price"
>
{convertToLocale({
amount: original_total / item.quantity,
currency_code: currencyCode,
})}
</span>
</p>
{style === "default" && (
<span className="text-ui-fg-interactive">-{percentage_diff}%</span>
)}
</>
)}
<span
className={clx("text-base-regular", {
"text-ui-fg-interactive": hasReducedPrice,
})}
data-testid="product-unit-price"
>
{convertToLocale({
amount: total / item.quantity,
currency_code: currencyCode,
})}
</span>
</div>
)
}
export default LineItemUnitPrice

View File

@@ -0,0 +1,32 @@
"use client"
import Link from "next/link"
import { useParams } from "next/navigation"
import React from "react"
/**
* Use this component to create a Next.js `<Link />` that persists the current country code in the url,
* without having to explicitly pass it as a prop.
*/
const LocalizedClientLink = ({
children,
href,
...props
}: {
children?: React.ReactNode
href: string
className?: string
onClick?: () => void
passHref?: true
[x: string]: any
}) => {
const { countryCode } = useParams()
return (
<Link href={`/${countryCode}${href}`} {...props}>
{children}
</Link>
)
}
export default LocalizedClientLink

View File

@@ -0,0 +1,118 @@
import { Dialog, Transition } from "@headlessui/react"
import { clx } from "@medusajs/ui"
import React, { Fragment } from "react"
import { ModalProvider, useModal } from "@lib/context/modal-context"
import X from "@modules/common/icons/x"
type ModalProps = {
isOpen: boolean
close: () => void
size?: "small" | "medium" | "large"
search?: boolean
children: React.ReactNode
'data-testid'?: string
}
const Modal = ({
isOpen,
close,
size = "medium",
search = false,
children,
'data-testid': dataTestId
}: ModalProps) => {
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-[75]" onClose={close}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-opacity-75 backdrop-blur-md h-screen" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-hidden">
<div
className={clx(
"flex min-h-full h-full justify-center p-4 text-center",
{
"items-center": !search,
"items-start": search,
}
)}
>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel
data-testid={dataTestId}
className={clx(
"flex flex-col justify-start w-full transform p-5 text-left align-middle transition-all max-h-[75vh] h-fit",
{
"max-w-md": size === "small",
"max-w-xl": size === "medium",
"max-w-3xl": size === "large",
"bg-transparent shadow-none": search,
"bg-white shadow-xl border rounded-rounded": !search,
}
)}
>
<ModalProvider close={close}>{children}</ModalProvider>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
)
}
const Title: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { close } = useModal()
return (
<Dialog.Title className="flex items-center justify-between">
<div className="text-large-semi">{children}</div>
<div>
<button onClick={close} data-testid="close-modal-button">
<X size={20} />
</button>
</div>
</Dialog.Title>
)
}
const Description: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<Dialog.Description className="flex text-small-regular text-ui-fg-base items-center justify-center pt-2 pb-4 h-full">
{children}
</Dialog.Description>
)
}
const Body: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return <div className="flex justify-center">{children}</div>
}
const Footer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return <div className="flex items-center justify-end gap-x-4">{children}</div>
}
Modal.Title = Title
Modal.Description = Description
Modal.Body = Body
Modal.Footer = Footer
export default Modal

View File

@@ -0,0 +1,74 @@
import { ChevronUpDown } from "@medusajs/icons"
import { clx } from "@medusajs/ui"
import {
SelectHTMLAttributes,
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
export type NativeSelectProps = {
placeholder?: string
errors?: Record<string, unknown>
touched?: Record<string, unknown>
} & SelectHTMLAttributes<HTMLSelectElement>
const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
(
{ placeholder = "Select...", defaultValue, className, children, ...props },
ref
) => {
const innerRef = useRef<HTMLSelectElement>(null)
const [isPlaceholder, setIsPlaceholder] = useState(false)
useImperativeHandle<HTMLSelectElement | null, HTMLSelectElement | null>(
ref,
() => innerRef.current
)
useEffect(() => {
if (innerRef.current && innerRef.current.value === "") {
setIsPlaceholder(true)
} else {
setIsPlaceholder(false)
}
}, [innerRef.current?.value])
return (
<div>
<div
onFocus={() => innerRef.current?.focus()}
onBlur={() => innerRef.current?.blur()}
className={clx(
"relative flex items-center text-base-regular border border-ui-border-base bg-ui-bg-subtle rounded-md hover:bg-ui-bg-field-hover",
className,
{
"text-ui-fg-muted": isPlaceholder,
}
)}
>
<select
ref={innerRef}
defaultValue={defaultValue}
{...props}
className="appearance-none flex-1 bg-transparent border-none px-4 py-2.5 transition-colors duration-150 outline-none "
>
<option disabled value="">
{placeholder}
</option>
{children}
</select>
<span className="absolute right-4 inset-y-0 flex items-center pointer-events-none ">
<ChevronUpDown />
</span>
</div>
</div>
)
}
)
NativeSelect.displayName = "NativeSelect"
export default NativeSelect

View File

@@ -0,0 +1,27 @@
const Radio = ({ checked, 'data-testid': dataTestId }: { checked: boolean, 'data-testid'?: string }) => {
return (
<>
<button
type="button"
role="radio"
aria-checked="true"
data-state={checked ? "checked" : "unchecked"}
className="group relative flex h-5 w-5 items-center justify-center outline-none"
data-testid={dataTestId || 'radio-button'}
>
<div className="shadow-borders-base group-hover:shadow-borders-strong-with-shadow bg-ui-bg-base group-data-[state=checked]:bg-ui-bg-interactive group-data-[state=checked]:shadow-borders-interactive group-focus:!shadow-borders-interactive-with-focus group-disabled:!bg-ui-bg-disabled group-disabled:!shadow-borders-base flex h-[14px] w-[14px] items-center justify-center rounded-full transition-all">
{checked && (
<span
data-state={checked ? "checked" : "unchecked"}
className="group flex items-center justify-center"
>
<div className="bg-ui-bg-base shadow-details-contrast-on-bg-interactive group-disabled:bg-ui-fg-disabled rounded-full group-disabled:shadow-none h-1.5 w-1.5"></div>
</span>
)}
</div>
</button>
</>
)
}
export default Radio

View File

@@ -0,0 +1,37 @@
import React from "react"
import { IconProps } from "types/icon"
const Back: React.FC<IconProps> = ({
size = "16",
color = "currentColor",
...attributes
}) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...attributes}
>
<path
d="M4 3.5V9.5H10"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4.09714 14.014C4.28641 15.7971 4.97372 16.7931 6.22746 18.0783C7.4812 19.3635 9.13155 20.1915 10.9137 20.4293C12.6958 20.6671 14.5064 20.301 16.0549 19.3898C17.6033 18.4785 18.8 17.0749 19.4527 15.4042C20.1054 13.7335 20.1764 11.8926 19.6543 10.1769C19.1322 8.46112 18.0472 6.97003 16.5735 5.94286C15.0997 4.91569 13.3227 4.412 11.5275 4.51261C9.73236 4.61323 8.02312 5.31232 6.6741 6.4977L4 8.89769"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export default Back

View File

@@ -0,0 +1,26 @@
import React from "react"
import { IconProps } from "types/icon"
const Ideal: React.FC<IconProps> = ({
size = "20",
color = "currentColor",
...attributes
}) => {
return (
<svg
width="24px"
height="24px"
viewBox="0 0 24 24"
role="img"
xmlns="http://www.w3.org/2000/svg"
fill={color}
{...attributes}
>
<title>Bancontact icon</title>
<path d="M21.385 9.768h-7.074l-4.293 5.022H1.557L3.84 12.1H1.122C.505 12.1 0 12.616 0 13.25v2.428c0 .633.505 1.15 1.122 1.15h12.933c.617 0 1.46-.384 1.874-.854l1.956-2.225 3.469-3.946.031-.035zm-1.123 1.279l-.751.855.75-.855zm2.616-3.875H9.982c-.617 0-1.462.384-1.876.853l-5.49 6.208h7.047l4.368-5.02h8.424l-2.263 2.689h2.686c.617 0 1.122-.518 1.122-1.151V8.323c0-.633-.505-1.15-1.122-1.15zm-1.87 3.024l-.374.427-.1.114.474-.54z" />
</svg>
)
}
export default Ideal

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