Initial commit

main
duc1607 2 years ago
commit cd4d75d0c4

@ -0,0 +1,3 @@
module.exports = {
extends: ["next/core-web-vitals"]
};

48
.gitignore vendored

@ -0,0 +1,48 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# IDEs
.idea
.vscode
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# typescript
*.tsbuildinfo
node_modules
.yarn
.swc
dump.rdb

@ -0,0 +1,8 @@
{
"arrowParens": "always",
"semi": false,
"endOfLine": "auto",
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5"
}

@ -0,0 +1 @@
nodeLinker: node-modules

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Medusa
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

@ -0,0 +1,216 @@
<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 14 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/create-medusa-app) 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
- Search with Algolia
- Product Collections
- Cart
- Checkout with PayPal and Stripe
- User Accounts
- Order Details
- Next.js 14
- Full App Router support with [Dynamic Routes](https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes) and [Route Groups](https://nextjs.org/docs/app/building-your-application/routing/route-groups)
- [Product Module](https://docs.medusajs.com/modules/products/serverless-module) support (beta)
# 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/)
- [Paypal](https://www.paypal.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>
NEXT_PUBLIC_PAYPAL_CLIENT_ID=<your-paypal-client-id>
```
You will 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/add-plugins/stripe) and [PayPal](https://docs.medusajs.com/add-plugins/paypal) in your Medusa project.
# Search integration
This starter is configured to support using the `medusa-search-meilisearch` plugin out of the box. To enable search you will need to enable the feature flag in `./store.config.json`, which you do by changing the config to this:
```javascript
{
"features": {
// other features...
"search": true
}
}
```
Before you can search you will need to install the plugin in your Medusa server, for a written guide on how to do this [see our documentation](https://docs.medusajs.com/add-plugins/meilisearch).
The search components in this starter are developed with Algolia's `react-instant-search-hooks-web` library which should make it possible for you to seemlesly change your search provider to Algolia instead of MeiliSearch.
To do this you will need to add `algoliasearch` to the project, by running
```shell
yarn add algoliasearch
```
After this you will need to switch the current MeiliSearch `SearchClient` out with a Alogolia client. To do this update `@lib/search-client`.
```ts
import algoliasearch from "algoliasearch/lite"
const appId = process.env.NEXT_PUBLIC_SEARCH_APP_ID || "test_app_id" // You should add this to your environment variables
const apiKey = process.env.NEXT_PUBLIC_SEARCH_API_KEY || "test_key"
export const searchClient = algoliasearch(appId, apiKey)
export const SEARCH_INDEX_NAME =
process.env.NEXT_PUBLIC_INDEX_NAME || "products"
```
Then, in `src/app/(main)/search/actions.ts`, remove the MeiliSearch code (line 10-16) and uncomment the Algolia code.
```ts
"use server"
import { searchClient, SEARCH_INDEX_NAME } from "@lib/search-client"
/**
* Uses MeiliSearch or Algolia to search for a query
* @param {string} query - search query
*/
export async function search(query: string) {
const index = searchClient.initIndex(SEARCH_INDEX_NAME)
const { hits } = await index.search(query)
return hits
}
```
After this you will need to set up Algolia with your Medusa server, and then you should be good to go. For a more thorough walkthrough of using Algolia with Medusa [see our documentation](https://docs.medusajs.com/add-plugins/algolia), and the [documentation for using `react-instantsearch-hooks-web`](https://www.algolia.com/doc/guides/building-search-ui/getting-started/react-hooks/).
# Serverless Modules
> Serverless Modules are currently in beta. You can learn more about them [here](https://docs.medusajs.com/experimental). In addition, the Serverless Modules in the Next.js storefront can't be used without the Medusa backend running at the moment.
This starter has full support for our new experimental [Product Module](https://docs.medusajs.com/experimental/product/overview) and [Pricing Module](https://docs.medusajs.com/experimental/pricing/overview) for retrieving and manipulating product and pricing data directly from a serverless function. This keeps your product logic close to the frontend, making it easy to customize or extend Medusa's core functionality from within your Next.js project.
By default, this starter uses the standard Medusa API for product and collection retrieval.
To enable the new modules on your server, refer to their [docs](https://docs.medusajs.com/experimental).
Then, make sure to set the following environment variables in your Next.js storefront project:
> WARNING: This is a one way process. Once you opt in to these features and update your database, there's no way back. Proceed with caution.
- `POSTGRES_URL`: the URL of your PostgreSQL databsae.
- `NEXT_PUBLIC_BASE_URL`: the URL of your storefront's base URL. If you're running it locally, it should be http://localhost:8000.
After that, add the following environment variable to **both your Next.js storefront and Medusa backend** to enable the feature flag:
- `MEDUSA_FF_MEDUSA_V2=true`
Finally, run migrations in your Medusa backend to prepare your database for the new modules.
```shell
npx medusa migrations run
```
Make sure the Medusa backend is running, then start (or restart) your Next.js storefront.
Done! All product and collection data should now be coming from the module. The Product Module routes are all in `src/app/api` for you to edit and play around with.
> Deploying to Vercel? If you're not planning on using the serverless modules, you might encounter errors when deploying to Vercel. You can safely delete or exclude the `src/app/api` folder before deploying. The API routes are only used by the serverless modules.
# 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)

@ -0,0 +1,8 @@
{
"baseUrl": "http://localhost:8000",
"env": {
"codeCoverage": {
"url": "/api/__coverage__"
}
}
}

@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

@ -0,0 +1,38 @@
describe("Product page", () => {
it("fetches product with handle [t-shirt]", () => {
cy.visit("/products/t-shirt")
cy.get("h1").contains("Medusa T-Shirt")
})
it("adds a product to the cart", () => {
cy.visit("/products/t-shirt")
cy.get("button").click()
cy.get("[data-cy=cart_quantity]").contains("1")
})
it("adds a product twice to the cart", () => {
cy.visit("/products/t-shirt")
cy.get("button").click()
cy.get("button").click()
cy.get("[data-cy=cart_quantity]").contains("2")
})
it("changes the current image by clicking a thumbnail", () => {
cy.visit("/products/t-shirt")
cy.get("[data-cy=current_image]")
.should("have.attr", "src")
.and("match", /.+(tee\-black\-front).+/)
cy.get("[data-cy=product_image_2]").click()
cy.get("[data-cy=current_image]")
.should("have.attr", "src")
.and("match", /.+(tee\-black\-back).+/)
})
})

@ -0,0 +1,27 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
// eslint-disable-next-line no-unused-vars
module.exports = (on, config) => {
// require("@cypress/code-coverage/task")(on, config)
// add other tasks to be registered here
// IMPORTANT to return the config object
// with the any changed environment variables
return config
}

@ -0,0 +1,25 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })

@ -0,0 +1,20 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import "./commands"
// Alternatively you can use CommonJS syntax:
// require('./commands')

@ -0,0 +1,2 @@
[template.environment]
NEXT_PUBLIC_MEDUSA_BACKEND_URL="URL of your Medusa Server"

5
next-env.d.ts vendored

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

@ -0,0 +1,19 @@
const excludedPaths = ["/checkout", "/account/*"]
module.exports = {
siteUrl: process.env.NEXT_PUBLIC_VERCEL_URL,
generateRobotsTxt: true,
exclude: excludedPaths + ["/[sitemap]"],
robotsTxtOptions: {
policies: [
{
userAgent: "*",
allow: "/",
},
{
userAgent: "*",
disallow: excludedPaths,
},
],
},
}

@ -0,0 +1,35 @@
const { withStoreConfig } = require("./store-config")
const store = require("./store.config.json")
module.exports = withStoreConfig({
experimental: {
serverComponentsExternalPackages: [
"@medusajs/product",
"@medusajs/modules-sdk",
],
},
features: store.features,
reactStrictMode: true,
images: {
remotePatterns: [
{
protocol: "http",
hostname: "localhost",
},
{
protocol: "https",
hostname: "medusa-public-images.s3.eu-west-1.amazonaws.com",
},
{
protocol: "https",
hostname: "medusa-server-testing.s3.amazonaws.com",
},
{
protocol: "https",
hostname: "medusa-server-testing.s3.us-east-1.amazonaws.com",
},
],
},
})
console.log("next.config.js", JSON.stringify(module.exports, null, 2))

15366
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,71 @@
{
"name": "medusa-next",
"version": "1.0.3",
"private": true,
"author": "Kasper Fabricius Kristensen <kasper@medusajs.com> (https://www.medusajs.com)",
"description": "Next.js starter to be used with Medusa server",
"keywords": [
"medusa-storefront"
],
"scripts": {
"dev": "next dev -p 8000",
"build": "next build",
"start": "next start -p 8000",
"lint": "next lint",
"cypress": "cypress open",
"analyze": "ANALYZE=true next build"
},
"resolutions": {
"webpack": "^5",
"@types/react": "17.0.40"
},
"dependencies": {
"@headlessui/react": "^1.6.1",
"@hookform/error-message": "^2.0.0",
"@medusajs/link-modules": "^0.2.3",
"@medusajs/medusa-js": "^6.1.4",
"@medusajs/modules-sdk": "^1.12.3",
"@medusajs/pricing": "^0.1.4",
"@medusajs/product": "^0.3.4",
"@medusajs/ui": "^2.2.0",
"@meilisearch/instant-meilisearch": "^0.7.1",
"@paypal/paypal-js": "^5.0.6",
"@paypal/react-paypal-js": "^7.8.1",
"@stripe/react-stripe-js": "^1.7.2",
"@stripe/stripe-js": "^1.29.0",
"@tanstack/react-query": "^4.22.4",
"algoliasearch": "^4.20.0",
"clsx": "^1.1.1",
"lodash": "^4.17.21",
"medusa-react": "^9.0.0",
"next": "^14.0.0",
"react": "^18.2.0",
"react-country-flag": "^3.0.2",
"react-dom": "^18.2.0",
"react-hook-form": "^7.30.0",
"react-instantsearch-hooks-web": "^6.29.0",
"react-intersection-observer": "^9.3.4",
"sharp": "^0.30.7",
"tailwindcss-radix": "^2.8.0",
"webpack": "^5"
},
"devDependencies": {
"@babel/core": "^7.17.5",
"@medusajs/client-types": "^0.2.2",
"@medusajs/medusa": "^1.18.0",
"@medusajs/ui-preset": "^1.0.2",
"@types/lodash": "^4.14.195",
"@types/node": "17.0.21",
"@types/react": "17.0.40",
"@types/react-instantsearch-dom": "^6.12.3",
"autoprefixer": "^10.4.2",
"babel-loader": "^8.2.3",
"cypress": "^9.5.2",
"eslint": "8.10.0",
"eslint-config-next": "^13.4.5",
"postcss": "^8.4.8",
"prettier": "^2.8.8",
"tailwindcss": "^3.0.23",
"typescript": "4.6.2"
}
}

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@ -0,0 +1,10 @@
import CheckoutTemplate from "@modules/checkout/templates"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Checkout",
}
export default function Checkout() {
return <CheckoutTemplate />
}

@ -0,0 +1,21 @@
import { Metadata } from "next"
import Link from "next/link"
export const metadata: Metadata = {
title: "404",
description: "Something went wrong",
}
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-64px)]">
<h1 className="text-2xl-semi text-gry-900">Page not found</h1>
<p className="text-small-regular text-gray-700">
The page you tried to access does not exist.
</p>
<Link href="/" className="mt-4 underline text-base-regular text-gray-900">
Go to frontpage
</Link>
</div>
)
}

@ -0,0 +1,5 @@
import SkeletonCollectionPage from "@modules/skeletons/templates/skeleton-collection-page"
export default function Loading() {
return <SkeletonCollectionPage />
}

@ -0,0 +1,37 @@
import { getCategoryByHandle } from "@lib/data"
import CategoryTemplate from "@modules/categories/templates"
import { Metadata } from "next"
import { notFound } from "next/navigation"
type Props = {
params: { category: string[] }
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { product_categories } = await getCategoryByHandle(
params.category
).catch((err) => {
notFound()
})
const category = product_categories[0]
if (!category) {
notFound()
}
return {
title: `${category.name} | Medusa Store`,
description: `${category.name} category`,
}
}
export default async function CategoryPage({ params }: Props) {
const { product_categories } = await getCategoryByHandle(
params.category
).catch((err) => {
notFound()
})
return <CategoryTemplate categories={product_categories} />
}

@ -0,0 +1,11 @@
import LoginTemplate from "@modules/account/templates/login-template"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Sign in",
description: "Sign in to your Medusa Store account.",
}
export default function Login() {
return <LoginTemplate />
}

@ -0,0 +1,11 @@
import AddressesTemplate from "@modules/account/templates/addresses-template"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Addresses",
description: "View your addresses",
}
export default function Addresses() {
return <AddressesTemplate />
}

@ -0,0 +1,9 @@
import AccountLayout from "@modules/account/templates/account-layout"
export default function AccountPageLayout({
children,
}: {
children: React.ReactNode
}) {
return <AccountLayout>{children}</AccountLayout>
}

@ -0,0 +1,11 @@
import OrdersTemplate from "@modules/account/templates/orders-template"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Orders",
description: "Overview of your previous orders..",
}
export default function Orders() {
return <OrdersTemplate />
}

@ -0,0 +1,11 @@
import OverviewTemplate from "@modules/account/templates/overview-template"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Account",
description: "Overview of your account activity.",
}
export default function Account() {
return <OverviewTemplate />
}

@ -0,0 +1,11 @@
import ProfileTemplate from "@modules/account/templates/profile-template"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Profile",
description: "View and edit your Medusa Store profile.",
}
export default function Profile() {
return <ProfileTemplate />
}

@ -0,0 +1,11 @@
import CartTemplate from "@modules/cart/templates"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Cart",
description: "View your cart",
}
export default function Cart() {
return <CartTemplate />
}

@ -0,0 +1,5 @@
import SkeletonCollectionPage from "@modules/skeletons/templates/skeleton-collection-page"
export default function Loading() {
return <SkeletonCollectionPage />
}

@ -0,0 +1,31 @@
import { getCollectionByHandle } from "@lib/data"
import CollectionTemplate from "@modules/collections/templates"
import { Metadata } from "next"
import { notFound } from "next/navigation"
type Props = {
params: { handle: string }
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { collections } = await getCollectionByHandle(params.handle)
const collection = collections[0]
if (!collection) {
notFound()
}
return {
title: `${collection.title} | Medusa Store`,
description: `${collection.title} collection`,
}
}
export default async function CollectionPage({ params }: Props) {
const { collections } = await getCollectionByHandle(params.handle)
const collection = collections[0]
return <CollectionTemplate collection={collection} />
}

@ -0,0 +1,16 @@
import Footer from "@modules/layout/templates/footer"
import Nav from "@modules/layout/templates/nav"
export default function PageLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<Nav />
{children}
<Footer />
</>
)
}

@ -0,0 +1,21 @@
import { Metadata } from "next"
import Link from "next/link"
export const metadata: Metadata = {
title: "404",
description: "Something went wrong",
}
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-64px)]">
<h1 className="text-2xl-semi text-gry-900">Page not found</h1>
<p className="text-small-regular text-gray-700">
The page you tried to access does not exist.
</p>
<Link href="/" className="mt-4 underline text-base-regular text-gray-900">
Go to frontpage
</Link>
</div>
)
}

@ -0,0 +1,5 @@
import SkeletonOrderConfirmed from "@modules/skeletons/templates/skeleton-order-confirmed"
export default function Loading() {
return <SkeletonOrderConfirmed />
}

@ -0,0 +1,28 @@
import medusaRequest from "@lib/medusa-fetch"
import OrderCompletedTemplate from "@modules/order/templates/order-completed-template"
import { Metadata } from "next"
type Props = {
params: { id: string }
}
async function getOrder(id: string) {
const res = await medusaRequest("GET", `/orders/${id}`)
if (!res.ok) {
throw new Error(`Failed to fetch order: ${id}`)
}
return res.body
}
export const metadata: Metadata = {
title: "Order Confirmed",
description: "You purchase was successful",
}
export default async function CollectionPage({ params }: Props) {
const { order } = await getOrder(params.id)
return <OrderCompletedTemplate order={order} />
}

@ -0,0 +1,5 @@
import SkeletonOrderConfirmed from "@modules/skeletons/templates/skeleton-order-confirmed"
export default function Loading() {
return <SkeletonOrderConfirmed />
}

@ -0,0 +1,33 @@
import medusaRequest from "@lib/medusa-fetch"
import OrderDetailsTemplate from "@modules/order/templates/order-details-template"
import { Metadata } from "next"
type Props = {
params: { id: string }
}
async function getOrder(id: string) {
const res = await medusaRequest("GET", `/orders/${id}`)
if (!res.ok) {
throw new Error(`Failed to fetch order: ${id}`)
}
return res.body
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { order } = await getOrder(params.id)
return {
title: `Order #${order.display_id}`,
description: `View your order`,
}
}
export default async function CollectionPage({ params }: Props) {
const { order } = await getOrder(params.id)
return <OrderDetailsTemplate order={order} />
}

@ -0,0 +1,25 @@
import { getCollectionsList } from "@lib/data"
import FeaturedProducts from "@modules/home/components/featured-products"
import Hero from "@modules/home/components/hero"
import SkeletonHomepageProducts from "@modules/skeletons/components/skeleton-homepage-products"
import { Metadata } from "next"
import { Suspense } from "react"
export const metadata: Metadata = {
title: "Medusa Next.js Starter Template",
description:
"A performant frontend ecommerce starter template with Next.js 14 and Medusa.",
}
export default async function Home() {
const { collections, count } = await getCollectionsList(0, 3)
return (
<>
<Hero />
<Suspense fallback={<SkeletonHomepageProducts count={count} />}>
<FeaturedProducts collections={collections} />
</Suspense>
</>
)
}

@ -0,0 +1,5 @@
import SkeletonProductPage from "@modules/skeletons/templates/skeleton-product-page"
export default function Loading() {
return <SkeletonProductPage />
}

@ -0,0 +1,37 @@
import { getProductByHandle } from "@lib/data"
import ProductTemplate from "@modules/products/templates"
import SkeletonProductPage from "@modules/skeletons/templates/skeleton-product-page"
import { Metadata } from "next"
import { notFound } from "next/navigation"
type Props = {
params: { handle: string }
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const data = await getProductByHandle(params.handle)
const product = data.products[0]
if (!product) {
notFound()
}
return {
title: `${product.title} | Medusa Store`,
description: `${product.title}`,
openGraph: {
title: `${product.title} | Medusa Store`,
description: `${product.title}`,
images: product.thumbnail ? [product.thumbnail] : [],
},
}
}
export default async function ProductPage({ params }: Props) {
const { products } = await getProductByHandle(params.handle).catch((err) => {
notFound()
})
return <ProductTemplate product={products[0]} />
}

@ -0,0 +1,19 @@
import { Metadata } from "next"
import { search } from "../actions"
import SearchResultsTemplate from "@modules/search/templates/search-results-template"
export const metadata: Metadata = {
title: "Search",
description: "Explore all of our products.",
}
export default async function StorePage({
params,
}: {
params: { query: string }
}) {
const { query } = params
const hits = await search(query)
return <SearchResultsTemplate query={query} hits={hits} />
}

@ -0,0 +1,24 @@
"use server"
import { searchClient, SEARCH_INDEX_NAME } from "@lib/search-client"
/**
* Uses MeiliSearch or Algolia to search for a query
* @param {string} query - search query
*/
export async function search(query: string) {
// MeiliSearch
const queries = [{ params: { query }, indexName: SEARCH_INDEX_NAME }]
const { results } = (await searchClient.search(queries)) as Record<
string,
any
>
const { hits } = results[0]
// In case you want to use Algolia instead of MeiliSearch, uncomment the following lines and delete the above lines.
// const index = searchClient.initIndex(SEARCH_INDEX_NAME)
// const { hits } = await index.search(query)
return hits
}

@ -0,0 +1,11 @@
import { Metadata } from "next"
import StoreTemplate from "@modules/store/templates"
export const metadata: Metadata = {
title: "Store",
description: "Explore all of our products.",
}
export default function StorePage() {
return <StoreTemplate />
}

@ -0,0 +1,13 @@
"use server"
import { revalidateTag } from "next/cache"
/**
* Revalidates each cache tag in the passed array
* @param {string[]} tags - array of tags to revalidate
*/
export async function revalidateTags(tags: string[]) {
tags.forEach((tag) => {
revalidateTag(tag)
})
}

@ -0,0 +1,177 @@
import { NextRequest, NextResponse } from "next/server"
import { initialize as initializeProductModule } from "@medusajs/product"
import { ProductDTO } from "@medusajs/types/dist/product"
import { IPricingModuleService } from "@medusajs/types"
import { notFound } from "next/navigation"
import { MedusaApp, Modules } from "@medusajs/modules-sdk"
import { getPricesByPriceSetId } from "@lib/util/get-prices-by-price-set-id"
/**
* This endpoint uses the serverless Product and Pricing Modules to retrieve a category and its products by handle.
* The module connects directly to you Medusa database to retrieve and manipulate data, without the need for a dedicated server.
* Read more about the Product Module here: https://docs.medusajs.com/modules/products/serverless-module
*/
export async function GET(
request: NextRequest,
{ params }: { params: Record<string, any> }
) {
// Initialize the Product Module
const productService = await initializeProductModule()
// Extract the query parameters
const searchParams = Object.fromEntries(request.nextUrl.searchParams)
const { page, limit } = searchParams
let { handle: categoryHandle } = params
const handle = categoryHandle.map((handle: string, index: number) =>
categoryHandle.slice(0, index + 1).join("/")
)
// Fetch the category by handle
const product_categories = await productService
.listCategories(
{
handle,
},
{
select: ["id", "handle", "name", "description"],
relations: ["category_children"],
take: handle.length,
}
)
.catch((e) => {
return notFound()
})
const category = product_categories[0]
if (!category) {
return notFound()
}
// Fetch the products by category id
const {
rows: products,
metadata: { count },
} = await getProductsByCategoryId(category.id, searchParams)
// Filter out unpublished products
const publishedProducts: ProductDTO[] = products.filter(
(product) => product.status === "published"
)
// Calculate the next page
const nextPage = parseInt(page) + parseInt(limit)
// Return the response
return NextResponse.json({
product_categories: Object.values(product_categories),
response: {
products: publishedProducts,
count,
},
nextPage: count > nextPage ? nextPage : null,
})
}
/**
* This function uses the serverless Product and Pricing Modules to retrieve products by category id.
* @param category_id The category id
* @param params The query parameters
* @returns The products and metadata
*/
async function getProductsByCategoryId(
category_id: string,
params: Record<string, any>
): Promise<{ rows: ProductDTO[]; metadata: Record<string, any> }> {
// Extract the query parameters
let { currency_code } = params
currency_code = currency_code && currency_code.toUpperCase()
// Initialize Remote Query with the Product and Pricing Modules
const { query, modules } = await MedusaApp({
modulesConfig: {
[Modules.PRODUCT]: true,
[Modules.PRICING]: true,
},
sharedResourcesConfig: {
database: { clientUrl: process.env.POSTGRES_URL },
},
})
// Set the filters for the query
const filters = {
take: parseInt(params.limit) || 100,
skip: parseInt(params.offset) || 0,
filters: {
category_id: [category_id],
},
currency_code,
}
// Set the GraphQL query
const productsQuery = `#graphql
query($filters: Record, $take: Int, $skip: Int) {
products(filters: $filters, take: $take, skip: $skip) {
id
title
handle
tags
status
collection
collection_id
thumbnail
images {
url
alt_text
id
}
options {
id
value
title
}
variants {
id
title
created_at
updated_at
thumbnail
inventory_quantity
material
weight
length
height
width
options {
id
value
title
}
price {
price_set {
id
}
}
}
}
}`
// Run the query
const { rows, metadata } = await query(productsQuery, filters)
// Calculate prices
const productsWithPrices = await getPricesByPriceSetId({
products: rows,
currency_code,
pricingService: modules.pricingService as unknown as IPricingModuleService,
})
// Return the response
return {
rows: productsWithPrices,
metadata,
}
}

@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server"
import { initialize as initializeProductModule } from "@medusajs/product"
import { notFound } from "next/navigation"
/**
* This endpoint uses the serverless Product Module to list and count all product categories.
* The module connects directly to your Medusa database to retrieve and manipulate data, without the need for a dedicated server.
* Read more about the Product Module here: https://docs.medusajs.com/modules/products/serverless-module
*/
export async function GET(request: NextRequest) {
const productService = await initializeProductModule()
const { offset, limit } = Object.fromEntries(request.nextUrl.searchParams)
const [product_categories, count] = await productService
.listAndCountCategories(
{},
{
select: ["id", "handle", "name", "description", "parent_category"],
relations: ["category_children"],
skip: parseInt(offset) || 0,
take: parseInt(limit) || 100,
}
)
.catch((e) => {
return notFound()
})
return NextResponse.json({
product_categories,
count,
})
}

@ -0,0 +1,169 @@
import { NextRequest, NextResponse } from "next/server"
import { notFound } from "next/navigation"
import { initialize as initializeProductModule } from "@medusajs/product"
import { MedusaApp, Modules } from "@medusajs/modules-sdk"
import { ProductCollectionDTO, ProductDTO } from "@medusajs/types/dist/product"
import { IPricingModuleService } from "@medusajs/types"
import { getPricesByPriceSetId } from "@lib/util/get-prices-by-price-set-id"
/**
* This endpoint uses the serverless Product Module to retrieve a collection and its products by handle.
* The module connects directly to your Medusa database to retrieve and manipulate data, without the need for a dedicated server.
* Read more about the Product Module here: https://docs.medusajs.com/modules/products/serverless-module
*/
export async function GET(
request: NextRequest,
{ params }: { params: Record<string, any> }
) {
// Initialize the Product Module
const productService = await initializeProductModule()
// Extract the query parameters
const { handle } = params
const searchParams = Object.fromEntries(request.nextUrl.searchParams)
const { page, limit } = searchParams
// Fetch the collections
const collections = await productService.listCollections()
// Create a map of collections by handle
const collectionsByHandle = new Map<string, ProductCollectionDTO>()
for (const collection of collections) {
collectionsByHandle.set(collection.handle, collection)
}
// Fetch the collection by handle
const collection = collectionsByHandle.get(handle)
if (!collection) {
return notFound()
}
// Fetch the products by collection id
const {
rows: products,
metadata: { count },
} = await getProductsByCollectionId(collection.id, searchParams)
// Filter out unpublished products
const publishedProducts: ProductDTO[] = products.filter(
(product) => product.status === "published"
)
// Calculate the next page
const nextPage = parseInt(page) + parseInt(limit)
// Return the response
return NextResponse.json({
collections: [collection],
response: {
products: publishedProducts,
count,
},
nextPage: count > nextPage ? nextPage : null,
})
}
/**
* This endpoint uses the serverless Product and Pricing Modules to retrieve a product list.
* @param collection_id The collection id to filter by
* @param params The query parameters
* @returns The products and metadata
*/
async function getProductsByCollectionId(
collection_id: string,
params: Record<string, any>
): Promise<{ rows: ProductDTO[]; metadata: Record<string, any> }> {
// Extract the query parameters
let { currency_code } = params
currency_code = currency_code && currency_code.toUpperCase()
// Initialize Remote Query with the Product and Pricing Modules
const { query, modules } = await MedusaApp({
modulesConfig: {
[Modules.PRODUCT]: true,
[Modules.PRICING]: true,
},
sharedResourcesConfig: {
database: { clientUrl: process.env.POSTGRES_URL },
},
})
// Set the filters for the query
const filters = {
take: parseInt(params.limit) || 100,
skip: parseInt(params.offset) || 0,
filters: {
collection_id: [collection_id],
},
currency_code,
}
// Set the GraphQL query
const productsQuery = `#graphql
query($filters: Record, $take: Int, $skip: Int) {
products(filters: $filters, take: $take, skip: $skip) {
id
title
handle
tags
status
collection
collection_id
thumbnail
images {
url
alt_text
id
}
options {
id
value
title
}
variants {
id
title
created_at
updated_at
thumbnail
inventory_quantity
material
weight
length
height
width
options {
id
value
title
}
price {
price_set {
id
}
}
}
}
}`
// Run the query
const { rows, metadata } = await query(productsQuery, filters)
// Calculate prices
const productsWithPrices = await getPricesByPriceSetId({
products: rows,
currency_code,
pricingService: modules.pricingService as unknown as IPricingModuleService,
})
// Return the response
return {
rows: productsWithPrices,
metadata,
}
}

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server"
import { initialize as initializeProductModule } from "@medusajs/product"
import { notFound } from "next/navigation"
/**
* This endpoint uses the serverless Product Module to list and count all product collections.
* The module connects directly to your Medusa database to retrieve and manipulate data, without the need for a dedicated server.
* Read more about the Product Module here: https://docs.medusajs.com/modules/products/serverless-module
*/
export async function GET(request: NextRequest) {
const productService = await initializeProductModule()
const { offset, limit } = Object.fromEntries(request.nextUrl.searchParams)
const [collections, count] = await productService
.listAndCountCollections(
{},
{
skip: parseInt(offset) || 0,
take: parseInt(limit) || 100,
}
)
.catch((e) => {
return notFound()
})
return NextResponse.json({
collections,
count,
})
}

@ -0,0 +1,40 @@
import { NextResponse, NextRequest } from "next/server"
import { initialize as initializeProductModule } from "@medusajs/product"
/**
* This endpoint uses the serverless Product Module to retrieve a product by handle.
* The module connects directly to your Medusa database to retrieve and manipulate data, without the need for a dedicated server.
* Read more about the Product Module here: https://docs.medusajs.com/modules/products/serverless-module
*/
export async function GET(
request: NextRequest,
{ params }: { params: Record<string, any> }
) {
// Extract the query parameters
const { handle } = params
// Initialize the Product Module
const productService = await initializeProductModule()
// Run the query
const products = await productService.list(
{ handle },
{
relations: [
"variants",
"variants.options",
"tags",
"options",
"options.values",
"images",
"description",
"collection",
"status",
],
take: 1,
}
)
// Return the response
return NextResponse.json({ products })
}

@ -0,0 +1,122 @@
import { NextRequest, NextResponse } from "next/server"
import { notFound } from "next/navigation"
import { MedusaApp, Modules } from "@medusajs/modules-sdk"
import { getPricesByPriceSetId } from "@lib/util/get-prices-by-price-set-id"
import { IPricingModuleService } from "@medusajs/types"
/**
* This endpoint uses the serverless Product and Pricing Modules to retrieve a product list.
* The modules connect directly to your Medusa database to retrieve and manipulate data, without the need for a dedicated server.
* Read more about the Product Module here: https://docs.medusajs.com/modules/products/serverless-module
*/
export async function GET(request: NextRequest) {
const queryParams = Object.fromEntries(request.nextUrl.searchParams)
const response = await getProducts(queryParams)
if (!response) {
return notFound()
}
return NextResponse.json(response)
}
async function getProducts(params: Record<string, any>) {
// Extract the query parameters
let { id, limit, offset, currency_code } = params
offset = offset && parseInt(offset)
limit = limit && parseInt(limit)
currency_code = currency_code && currency_code.toUpperCase()
// Initialize Remote Query with the Product and Pricing Modules
const { query, modules } = await MedusaApp({
modulesConfig: {
[Modules.PRODUCT]: true,
[Modules.PRICING]: true,
},
sharedResourcesConfig: {
database: { clientUrl: process.env.POSTGRES_URL },
},
injectedDependencies: {},
})
// Set the filters for the query
const filters = {
take: limit || 12,
skip: offset || 0,
id: id ? [id] : undefined,
context: { currency_code },
}
// Set the GraphQL query
const productsQuery = `#graphql
query($filters: Record, $id: String, $take: Int, $skip: Int) {
products(filters: $filters, id: $id, take: $take, skip: $skip) {
id
title
handle
tags
status
collection
collection_id
thumbnail
images {
url
alt_text
id
}
options {
id
value
title
}
variants {
id
title
created_at
updated_at
thumbnail
inventory_quantity
material
weight
length
height
width
options {
id
value
title
}
price {
price_set {
id
}
}
}
}
}`
const {
rows: products,
metadata: { count },
} = await query(productsQuery, filters)
// Calculate prices
const productsWithPrices = await getPricesByPriceSetId({
products,
currency_code,
pricingService: modules.pricingService as unknown as IPricingModuleService,
})
// Calculate the next page
const nextPage = offset + limit
// Return the response
return {
products: productsWithPrices,
count: count,
nextPage: count > nextPage ? nextPage : null,
}
}

@ -0,0 +1,18 @@
import Providers from "@modules/providers"
import "styles/globals.css"
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" data-mode="light">
<body>
<Providers>
<main className="relative">{children}</main>
</Providers>
</body>
</html>
)
}

@ -0,0 +1,21 @@
import { Metadata } from "next"
import Link from "next/link"
export const metadata: Metadata = {
title: "404",
description: "Something went wrong",
}
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-64px)]">
<h1 className="text-2xl-semi text-gry-900">Page not found</h1>
<p className="text-small-regular text-gray-700">
The page you tried to access does not exist.
</p>
<Link href="/" className="mt-4 underline text-base-regular text-gray-900">
Go to frontpage
</Link>
</div>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

@ -0,0 +1,23 @@
import Medusa from "@medusajs/medusa-js"
import { QueryClient } from "@tanstack/react-query"
// Defaults to standard port for Medusa server
let MEDUSA_BACKEND_URL = "http://localhost:9000"
if (process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL) {
MEDUSA_BACKEND_URL = process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 60 * 24,
retry: 1,
},
},
})
const medusaClient = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
export { MEDUSA_BACKEND_URL, queryClient, medusaClient }

@ -0,0 +1 @@
export const IS_BROWSER = typeof window !== "undefined"

@ -0,0 +1,90 @@
"use client"
import { medusaClient } from "@lib/config"
import { Customer } from "@medusajs/medusa"
import { useMutation } from "@tanstack/react-query"
import { useMeCustomer } from "medusa-react"
import { useRouter } from "next/navigation"
import React, { createContext, useCallback, useContext, useState } from "react"
export enum LOGIN_VIEW {
SIGN_IN = "sign-in",
REGISTER = "register",
}
interface AccountContext {
customer?: Omit<Customer, "password_hash">
retrievingCustomer: boolean
loginView: [LOGIN_VIEW, React.Dispatch<React.SetStateAction<LOGIN_VIEW>>]
checkSession: () => void
refetchCustomer: () => void
handleLogout: () => void
}
const AccountContext = createContext<AccountContext | null>(null)
interface AccountProviderProps {
children?: React.ReactNode
}
const handleDeleteSession = () => {
return medusaClient.auth.deleteSession()
}
export const AccountProvider = ({ children }: AccountProviderProps) => {
const {
customer,
isLoading: retrievingCustomer,
refetch,
remove,
} = useMeCustomer({ onError: () => {} })
const loginView = useState<LOGIN_VIEW>(LOGIN_VIEW.SIGN_IN)
const router = useRouter()
const checkSession = useCallback(() => {
if (!customer && !retrievingCustomer) {
router.push("/account/login")
}
}, [customer, retrievingCustomer, router])
const useDeleteSession = useMutation({
mutationFn: handleDeleteSession,
mutationKey: ["delete-session"],
})
const handleLogout = () => {
useDeleteSession.mutate(undefined, {
onSuccess: () => {
remove()
loginView[1](LOGIN_VIEW.SIGN_IN)
router.push("/")
},
})
}
return (
<AccountContext.Provider
value={{
customer,
retrievingCustomer,
loginView,
checkSession,
refetchCustomer: refetch,
handleLogout,
}}
>
{children}
</AccountContext.Provider>
)
}
export const useAccount = () => {
const context = useContext(AccountContext)
if (context === null) {
throw new Error("useAccount must be used within a AccountProvider")
}
return context
}

@ -0,0 +1,71 @@
"use client"
import useToggleState from "@lib/hooks/use-toggle-state"
import { createContext, useContext, useEffect, useState } from "react"
interface CartDropdownContext {
state: boolean
open: () => void
timedOpen: () => void
close: () => void
}
export const CartDropdownContext = createContext<CartDropdownContext | null>(
null
)
export const CartDropdownProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const { state, close, open } = useToggleState()
const [activeTimer, setActiveTimer] = useState<NodeJS.Timer | undefined>(
undefined
)
const timedOpen = () => {
open()
const timer = setTimeout(close, 5000)
setActiveTimer(timer)
}
const openAndCancel = () => {
if (activeTimer) {
clearTimeout(activeTimer)
}
open()
}
// Clean up the timer when the component unmounts
useEffect(() => {
return () => {
if (activeTimer) {
clearTimeout(activeTimer)
}
}
}, [activeTimer])
return (
<CartDropdownContext.Provider
value={{ state, close, open: openAndCancel, timedOpen }}
>
{children}
</CartDropdownContext.Provider>
)
}
export const useCartDropdown = () => {
const context = useContext(CartDropdownContext)
if (context === null) {
throw new Error(
"useCartDropdown must be used within a CartDropdownProvider"
)
}
return context
}

@ -0,0 +1,473 @@
"use client"
import { medusaClient } from "@lib/config"
import useToggleState, { StateType } from "@lib/hooks/use-toggle-state"
import {
Address,
Cart,
Customer,
StorePostCartsCartReq,
} from "@medusajs/medusa"
import Wrapper from "@modules/checkout/components/payment-wrapper"
import { isEqual } from "lodash"
import {
formatAmount,
useCart,
useCartShippingOptions,
useMeCustomer,
useRegions,
useSetPaymentSession,
useUpdateCart,
} from "medusa-react"
import { useRouter } from "next/navigation"
import React, { createContext, useContext, useEffect, useMemo } from "react"
import { FormProvider, useForm, useFormContext } from "react-hook-form"
import { useStore } from "./store-context"
import Spinner from "@modules/common/icons/spinner"
type AddressValues = {
first_name: string
last_name: string
company: string
address_1: string
address_2: string
city: string
province: string
postal_code: string
country_code: string
phone: string
}
export type CheckoutFormValues = {
shipping_address: AddressValues
billing_address: AddressValues
email: string
}
interface CheckoutContext {
cart?: Omit<Cart, "refundable_amount" | "refunded_total">
shippingMethods: { label?: string; value?: string; price: string }[]
isLoading: boolean
addressReady: boolean
shippingReady: boolean
paymentReady: boolean
readyToComplete: boolean
sameAsBilling: StateType
editAddresses: StateType
editShipping: StateType
editPayment: StateType
isCompleting: StateType
initPayment: () => Promise<void>
setAddresses: (addresses: CheckoutFormValues) => void
setSavedAddress: (address: Address) => void
setShippingOption: (soId: string) => void
setPaymentSession: (providerId: string) => void
onPaymentCompleted: () => void
}
const CheckoutContext = createContext<CheckoutContext | null>(null)
interface CheckoutProviderProps {
children?: React.ReactNode
}
const IDEMPOTENCY_KEY = "create_payment_session_key"
export const CheckoutProvider = ({ children }: CheckoutProviderProps) => {
const {
cart,
setCart,
addShippingMethod: {
mutate: setShippingMethod,
isLoading: addingShippingMethod,
},
completeCheckout: { mutate: complete },
} = useCart()
const { customer } = useMeCustomer()
const { countryCode } = useStore()
const methods = useForm<CheckoutFormValues>({
defaultValues: mapFormValues(customer, cart, countryCode),
reValidateMode: "onChange",
})
const {
mutate: setPaymentSessionMutation,
isLoading: settingPaymentSession,
} = useSetPaymentSession(cart?.id!)
const { mutate: updateCart, isLoading: updatingCart } = useUpdateCart(
cart?.id!
)
const { shipping_options } = useCartShippingOptions(cart?.id!, {
enabled: !!cart?.id,
})
const { regions } = useRegions()
const { resetCart, setRegion } = useStore()
const { push } = useRouter()
const editAddresses = useToggleState()
const sameAsBilling = useToggleState(
cart?.billing_address && cart?.shipping_address
? isEqual(cart.billing_address, cart.shipping_address)
: true
)
const editShipping = useToggleState()
const editPayment = useToggleState()
/**
* Boolean that indicates if a part of the checkout is loading.
*/
const isLoading = useMemo(() => {
return addingShippingMethod || settingPaymentSession || updatingCart
}, [addingShippingMethod, settingPaymentSession, updatingCart])
/**
* Boolean that indicates if the checkout is ready to be completed. A checkout is ready to be completed if
* the user has supplied a email, shipping address, billing address, shipping method, and a method of payment.
*/
const { addressReady, shippingReady, paymentReady, readyToComplete } =
useMemo(() => {
const addressReady =
!!cart?.shipping_address && !!cart?.billing_address && !!cart?.email
const shippingReady =
addressReady &&
!!(
cart?.shipping_methods &&
cart.shipping_methods.length > 0 &&
cart.shipping_methods[0].shipping_option
)
const paymentReady = shippingReady && !!cart?.payment_session
const readyToComplete = addressReady && shippingReady && paymentReady
return {
addressReady,
shippingReady,
paymentReady,
readyToComplete,
}
}, [cart])
useEffect(() => {
if (addressReady && !shippingReady) {
editShipping.open()
}
}, [addressReady, shippingReady, editShipping])
const shippingMethods = useMemo(() => {
if (shipping_options && cart?.region) {
return shipping_options?.map((option) => ({
value: option.id,
label: option.name,
price: formatAmount({
amount: option.amount || 0,
region: cart.region,
}),
}))
}
return []
}, [shipping_options, cart])
/**
* Resets the form when the cart changed.
*/
useEffect(() => {
if (cart?.id) {
methods.reset(mapFormValues(customer, cart, countryCode))
}
}, [customer, cart, methods, countryCode])
useEffect(() => {
if (!cart) {
editAddresses.open()
return
}
if (cart?.shipping_address && cart?.billing_address) {
editAddresses.close()
return
}
editAddresses.open()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cart])
/**
* Method to set the selected shipping method for the cart. This is called when the user selects a shipping method, such as UPS, FedEx, etc.
*/
const setShippingOption = async (soId: string) => {
if (cart) {
setShippingMethod(
{ option_id: soId },
{
onSuccess: ({ cart }) => setCart(cart),
}
)
}
}
/**
* Method to create the payment sessions available for the cart. Uses a idempotency key to prevent duplicate requests.
*/
const initPayment = async () => {
if (cart?.id && !cart.payment_sessions?.length && cart?.items?.length) {
return medusaClient.carts
.createPaymentSessions(cart.id, {
"Idempotency-Key": IDEMPOTENCY_KEY,
})
.then(({ cart }) => cart && setCart(cart))
.catch((err) => err)
}
}
useEffect(() => {
// initialize payment session
const start = async () => {
await initPayment()
}
start()
}, [cart?.region, cart?.id, cart?.items])
/**
* Method to set the selected payment session for the cart. This is called when the user selects a payment provider, such as Stripe, PayPal, etc.
*/
const setPaymentSession = (providerId: string) => {
if (cart) {
setPaymentSessionMutation(
{
provider_id: providerId,
},
{
onSuccess: ({ cart }) => {
setCart(cart)
},
}
)
}
}
const setSavedAddress = (address: Address) => {
const setValue = methods.setValue
setValue("shipping_address", {
address_1: address.address_1 || "",
address_2: address.address_2 || "",
city: address.city || "",
country_code: address.country_code || "",
first_name: address.first_name || "",
last_name: address.last_name || "",
phone: address.phone || "",
postal_code: address.postal_code || "",
province: address.province || "",
company: address.company || "",
})
}
/**
* Method that validates if the cart's region matches the shipping address's region. If not, it will update the cart region.
*/
const validateRegion = (countryCode: string) => {
if (regions && cart) {
const region = regions.find((r) =>
r.countries.map((c) => c.iso_2).includes(countryCode)
)
if (region && region.id !== cart.region.id) {
setRegion(region.id, countryCode)
}
}
}
/**
* Method that sets the addresses and email on the cart.
*/
const setAddresses = (data: CheckoutFormValues) => {
const { shipping_address, billing_address, email } = data
validateRegion(shipping_address.country_code)
const payload: StorePostCartsCartReq = {
shipping_address,
email,
}
if (isEqual(shipping_address, billing_address)) {
sameAsBilling.open()
}
if (sameAsBilling.state) {
payload.billing_address = shipping_address
} else {
payload.billing_address = billing_address
}
updateCart(payload, {
onSuccess: ({ cart }) => setCart(cart),
})
}
const isCompleting = useToggleState()
/**
* Method to complete the checkout process. This is called when the user clicks the "Complete Checkout" button.
*/
const onPaymentCompleted = () => {
isCompleting.open()
complete(undefined, {
onSuccess: ({ data }) => {
push(`/order/confirmed/${data.id}`)
resetCart()
},
})
isCompleting.close()
}
return (
<FormProvider {...methods}>
<CheckoutContext.Provider
value={{
cart,
shippingMethods,
isLoading,
addressReady,
shippingReady,
paymentReady,
readyToComplete,
sameAsBilling,
editAddresses,
editShipping,
editPayment,
isCompleting,
initPayment,
setAddresses,
setSavedAddress,
setShippingOption,
setPaymentSession,
onPaymentCompleted,
}}
>
{isLoading && cart?.id === "" ? (
<div className="flex justify-center items-center h-screen">
<div className="w-auto">
<Spinner size={40} />
</div>
</div>
) : (
<Wrapper paymentSession={cart?.payment_session}>{children}</Wrapper>
)}
</CheckoutContext.Provider>
</FormProvider>
)
}
export const useCheckout = () => {
const context = useContext(CheckoutContext)
const form = useFormContext<CheckoutFormValues>()
if (context === null) {
throw new Error(
"useProductActionContext must be used within a ProductActionProvider"
)
}
return { ...context, ...form }
}
/**
* Method to map the fields of a potential customer and the cart to the checkout form values. Information is assigned with the following priority:
* 1. Cart information
* 2. Customer information
* 3. Default values - null
*/
const mapFormValues = (
customer?: Omit<Customer, "password_hash">,
cart?: Omit<Cart, "refundable_amount" | "refunded_total">,
currentCountry?: string
): CheckoutFormValues => {
const customerShippingAddress = customer?.shipping_addresses?.[0]
const customerBillingAddress = customer?.billing_address
return {
shipping_address: {
first_name:
cart?.shipping_address?.first_name ||
customerShippingAddress?.first_name ||
"",
last_name:
cart?.shipping_address?.last_name ||
customerShippingAddress?.last_name ||
"",
address_1:
cart?.shipping_address?.address_1 ||
customerShippingAddress?.address_1 ||
"",
address_2:
cart?.shipping_address?.address_2 ||
customerShippingAddress?.address_2 ||
"",
city: cart?.shipping_address?.city || customerShippingAddress?.city || "",
country_code:
currentCountry ||
cart?.shipping_address?.country_code ||
customerShippingAddress?.country_code ||
"",
province:
cart?.shipping_address?.province ||
customerShippingAddress?.province ||
"",
company:
cart?.shipping_address?.company ||
customerShippingAddress?.company ||
"",
postal_code:
cart?.shipping_address?.postal_code ||
customerShippingAddress?.postal_code ||
"",
phone:
cart?.shipping_address?.phone || customerShippingAddress?.phone || "",
},
billing_address: {
first_name:
cart?.billing_address?.first_name ||
customerBillingAddress?.first_name ||
"",
last_name:
cart?.billing_address?.last_name ||
customerBillingAddress?.last_name ||
"",
address_1:
cart?.billing_address?.address_1 ||
customerBillingAddress?.address_1 ||
"",
address_2:
cart?.billing_address?.address_2 ||
customerBillingAddress?.address_2 ||
"",
city: cart?.billing_address?.city || customerBillingAddress?.city || "",
country_code:
cart?.shipping_address?.country_code ||
customerBillingAddress?.country_code ||
"",
province:
cart?.shipping_address?.province ||
customerBillingAddress?.province ||
"",
company:
cart?.billing_address?.company || customerBillingAddress?.company || "",
postal_code:
cart?.billing_address?.postal_code ||
customerBillingAddress?.postal_code ||
"",
phone:
cart?.billing_address?.phone || customerBillingAddress?.phone || "",
},
email: cart?.email || customer?.email || "",
}
}

@ -0,0 +1,81 @@
"use client"
import useCurrentWidth from "@lib/hooks/use-current-width"
import useDebounce from "@lib/hooks/use-debounce"
import useToggleState from "@lib/hooks/use-toggle-state"
import {
createContext,
Dispatch,
SetStateAction,
useCallback,
useContext,
useEffect,
useState,
} from "react"
type ScreenType = "main" | "country" | "search"
interface MobileMenuContext {
state: boolean
open: () => void
close: () => void
toggle: () => void
screen: [ScreenType, Dispatch<SetStateAction<ScreenType>>]
}
export const MobileMenuContext = createContext<MobileMenuContext | null>(null)
export const MobileMenuProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const { state, close, open, toggle } = useToggleState()
const [screen, setScreen] = useState<ScreenType>("main")
const windowWidth = useCurrentWidth()
const debouncedWith = useDebounce(windowWidth, 200)
const closeMenu = useCallback(() => {
close()
setTimeout(() => {
setScreen("main")
}, 500)
}, [close])
useEffect(() => {
if (state && debouncedWith >= 1024) {
closeMenu()
}
}, [debouncedWith, state, closeMenu])
useEffect(() => {}, [debouncedWith])
return (
<MobileMenuContext.Provider
value={{
state,
close: closeMenu,
open,
toggle,
screen: [screen, setScreen],
}}
>
{children}
</MobileMenuContext.Provider>
)
}
export const useMobileMenu = () => {
const context = useContext(MobileMenuContext)
if (context === null) {
throw new Error(
"useCartDropdown must be used within a CartDropdownProvider"
)
}
return context
}

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

@ -0,0 +1,182 @@
"use client"
import { canBuy } from "@lib/util/can-buy"
import { findCheapestPrice } from "@lib/util/prices"
import isEqual from "lodash/isEqual"
import { formatVariantPrice, useCart } from "medusa-react"
import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from "react"
import { Variant } from "types/medusa"
import { useStore } from "./store-context"
import { PricedProduct } from "@medusajs/medusa/dist/types/pricing"
interface ProductContext {
formattedPrice: string
quantity: number
disabled: boolean
inStock: boolean
variant?: Variant
maxQuantityMet: boolean
options: Record<string, string>
updateOptions: (options: Record<string, string>) => void
increaseQuantity: () => void
decreaseQuantity: () => void
addToCart: () => void
}
const ProductActionContext = createContext<ProductContext | null>(null)
interface ProductProviderProps {
children?: React.ReactNode
product: PricedProduct
}
export const ProductProvider = ({
product,
children,
}: ProductProviderProps) => {
const [quantity, setQuantity] = useState<number>(1)
const [options, setOptions] = useState<Record<string, string>>({})
const [maxQuantityMet, setMaxQuantityMet] = useState<boolean>(false)
const [inStock, setInStock] = useState<boolean>(true)
const { addItem } = useStore()
const { cart } = useCart()
const variants = product.variants as unknown as Variant[]
useEffect(() => {
// initialize the option state
const optionObj: Record<string, string> = {}
for (const option of product.options || []) {
Object.assign(optionObj, { [option.id]: undefined })
}
setOptions(optionObj)
}, [product])
// memoized record of the product's variants
const variantRecord = useMemo(() => {
const map: Record<string, Record<string, string>> = {}
for (const variant of variants) {
const tmp: Record<string, string> = {}
for (const option of variant.options) {
tmp[option.option_id] = option.value
}
map[variant.id] = tmp
}
return map
}, [variants])
// memoized function to check if the current options are a valid variant
const variant = useMemo(() => {
let variantId: string | undefined = undefined
for (const key of Object.keys(variantRecord)) {
if (isEqual(variantRecord[key], options)) {
variantId = key
}
}
return variants.find((v) => v.id === variantId)
}, [options, variantRecord, variants])
// if product only has one variant, then select it
useEffect(() => {
if (variants.length === 1) {
setOptions(variantRecord[variants[0].id])
}
}, [variants, variantRecord])
const disabled = useMemo(() => {
return !variant
}, [variant])
// memoized function to get the price of the current variant
const formattedPrice = useMemo(() => {
if (variant && cart?.region) {
return formatVariantPrice({ variant, region: cart.region })
} else if (cart?.region) {
return findCheapestPrice(variants, cart.region)
} else {
// if no variant is selected, or we couldn't find a price for the region/currency
return "N/A"
}
}, [variant, variants, cart])
useEffect(() => {
if (variant) {
setInStock(canBuy(variant))
}
}, [variant])
const updateOptions = (update: Record<string, string>) => {
setOptions({ ...options, ...update })
}
const addToCart = () => {
if (variant) {
addItem({
variantId: variant.id,
quantity,
})
}
}
const increaseQuantity = () => {
const maxQuantity = variant?.inventory_quantity || 0
if (maxQuantity > quantity + 1) {
setQuantity(quantity + 1)
} else {
setMaxQuantityMet(true)
}
}
const decreaseQuantity = () => {
if (quantity > 1) {
setQuantity(quantity - 1)
if (maxQuantityMet) {
setMaxQuantityMet(false)
}
}
}
return (
<ProductActionContext.Provider
value={{
quantity,
maxQuantityMet,
disabled,
inStock,
options,
variant,
addToCart,
updateOptions,
decreaseQuantity,
increaseQuantity,
formattedPrice,
}}
>
{children}
</ProductActionContext.Provider>
)
}
export const useProductActions = () => {
const context = useContext(ProductActionContext)
if (context === null) {
throw new Error(
"useProductActionContext must be used within a ProductActionProvider"
)
}
return context
}

@ -0,0 +1,323 @@
"use client"
import { medusaClient } from "@lib/config"
import { handleError } from "@lib/util/handle-error"
import { Region } from "@medusajs/medusa"
import {
useCart,
useCreateLineItem,
useDeleteLineItem,
useUpdateLineItem,
} from "medusa-react"
import React, { useEffect, useState } from "react"
import { useCartDropdown } from "./cart-dropdown-context"
import { useSearchParams } from "next/navigation"
interface VariantInfoProps {
variantId: string
quantity: number
}
interface LineInfoProps {
lineId: string
quantity: number
}
interface StoreContext {
countryCode: string | undefined
setRegion: (regionId: string, countryCode: string) => void
addItem: (item: VariantInfoProps) => void
updateItem: (item: LineInfoProps) => void
deleteItem: (lineId: string) => void
resetCart: () => void
}
const StoreContext = React.createContext<StoreContext | null>(null)
export const useStore = () => {
const context = React.useContext(StoreContext)
if (context === null) {
throw new Error("useStore must be used within a StoreProvider")
}
return context
}
interface StoreProps {
children: React.ReactNode
}
const IS_SERVER = typeof window === "undefined"
const CART_KEY = "medusa_cart_id"
const REGION_KEY = "medusa_region"
export const StoreProvider = ({ children }: StoreProps) => {
const { cart, setCart, createCart, updateCart } = useCart()
const [countryCode, setCountryCode] = useState<string | undefined>(undefined)
const { timedOpen } = useCartDropdown()
const addLineItem = useCreateLineItem(cart?.id!)
const removeLineItem = useDeleteLineItem(cart?.id!)
const adjustLineItem = useUpdateLineItem(cart?.id!)
// check if the user is onboarding and sets the onboarding session storage
const searchParams = useSearchParams()
const onboardingCartId = searchParams.get("cart_id")
const isOnboarding = searchParams.get("onboarding")
useEffect(() => {
if (isOnboarding === "true") {
sessionStorage.setItem("onboarding", "true")
}
}, [isOnboarding])
const storeRegion = (regionId: string, countryCode: string) => {
if (!IS_SERVER) {
localStorage.setItem(
REGION_KEY,
JSON.stringify({ regionId, countryCode })
)
setCountryCode(countryCode)
}
}
useEffect(() => {
if (!IS_SERVER) {
const storedRegion = localStorage.getItem(REGION_KEY)
if (storedRegion) {
const { countryCode } = JSON.parse(storedRegion)
setCountryCode(countryCode)
}
}
}, [])
const getRegion = () => {
if (!IS_SERVER) {
const region = localStorage.getItem(REGION_KEY)
if (region) {
return JSON.parse(region) as { regionId: string; countryCode: string }
}
}
return null
}
const setRegion = async (regionId: string, countryCode: string) => {
await updateCart.mutateAsync(
{
region_id: regionId,
},
{
onSuccess: ({ cart }) => {
setCart(cart)
storeCart(cart.id)
storeRegion(regionId, countryCode)
},
onError: (error) => {
if (process.env.NODE_ENV === "development") {
console.error(error)
}
},
}
)
}
const ensureRegion = (region: Region, countryCode?: string | null) => {
if (!IS_SERVER) {
const { regionId, countryCode: defaultCountryCode } = getRegion() || {
regionId: region.id,
countryCode: region.countries[0].iso_2,
}
const finalCountryCode = countryCode || defaultCountryCode
if (regionId !== region.id) {
setRegion(region.id, finalCountryCode)
}
storeRegion(region.id, finalCountryCode)
setCountryCode(finalCountryCode)
}
}
const storeCart = (id: string) => {
if (!IS_SERVER) {
localStorage.setItem(CART_KEY, id)
}
}
const getCart = () => {
if (!IS_SERVER) {
return localStorage.getItem(CART_KEY)
}
return null
}
const deleteCart = () => {
if (!IS_SERVER) {
localStorage.removeItem(CART_KEY)
}
}
const deleteRegion = () => {
if (!IS_SERVER) {
localStorage.removeItem(REGION_KEY)
}
}
const createNewCart = async (regionId?: string) => {
await createCart.mutateAsync(
{ region_id: regionId },
{
onSuccess: ({ cart }) => {
setCart(cart)
storeCart(cart.id)
ensureRegion(cart.region, cart.shipping_address?.country_code)
},
onError: (error) => {
if (process.env.NODE_ENV === "development") {
console.error(error)
}
},
}
)
}
const resetCart = () => {
deleteCart()
const savedRegion = getRegion()
createCart.mutate(
{
region_id: savedRegion?.regionId,
},
{
onSuccess: ({ cart }) => {
setCart(cart)
storeCart(cart.id)
ensureRegion(cart.region, cart.shipping_address?.country_code)
},
onError: (error) => {
if (process.env.NODE_ENV === "development") {
console.error(error)
}
},
}
)
}
useEffect(() => {
const ensureCart = async () => {
const cartId = onboardingCartId || getCart()
const region = getRegion()
if (cartId) {
const cartRes = await medusaClient.carts
.retrieve(cartId)
.then(({ cart }) => {
return cart
})
.catch(async (_) => {
return null
})
if (!cartRes || cartRes.completed_at) {
deleteCart()
deleteRegion()
await createNewCart()
return
}
setCart(cartRes)
ensureRegion(cartRes.region)
} else {
await createNewCart(region?.regionId)
}
}
if (!IS_SERVER && !cart?.id) {
ensureCart()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const addItem = ({
variantId,
quantity,
}: {
variantId: string
quantity: number
}) => {
addLineItem.mutate(
{
variant_id: variantId,
quantity: quantity,
},
{
onSuccess: ({ cart }) => {
setCart(cart)
storeCart(cart.id)
timedOpen()
},
onError: (error) => {
handleError(error)
},
}
)
}
const deleteItem = (lineId: string) => {
removeLineItem.mutate(
{
lineId,
},
{
onSuccess: ({ cart }) => {
setCart(cart)
storeCart(cart.id)
},
onError: (error) => {
handleError(error)
},
}
)
}
const updateItem = ({
lineId,
quantity,
}: {
lineId: string
quantity: number
}) => {
adjustLineItem.mutate(
{
lineId,
quantity,
},
{
onSuccess: ({ cart }) => {
setCart(cart)
storeCart(cart.id)
},
onError: (error) => {
handleError(error)
},
}
)
}
return (
<StoreContext.Provider
value={{
countryCode,
setRegion,
addItem,
deleteItem,
updateItem,
resetCart,
}}
>
{children}
</StoreContext.Provider>
)
}

@ -0,0 +1,458 @@
import medusaRequest from "../medusa-fetch"
import {
StoreGetProductsParams,
Product,
ProductCategory,
ProductCollection,
} from "@medusajs/medusa"
import { PricedProduct } from "@medusajs/medusa/dist/types/pricing"
export type ProductCategoryWithChildren = Omit<
ProductCategory,
"category_children"
> & {
category_children: ProductCategory[]
}
/**
* This file contains functions for fetching products and collections from the Medusa API or the Medusa V2 Modules,
* depending on the feature flag. By default, the standard Medusa API is used. To use Medusa V2 set the feature flag to true.
*/
// The MEDUSA_FF_MEDUSA_V2 flag is set in the .env file of both the storefront and the server. It is used to determine whether to use the Medusa API or the Medusa V2 Modules.
let MEDUSA_V2_ENABLED = false
if (process.env.MEDUSA_FF_MEDUSA_V2) {
MEDUSA_V2_ENABLED = process.env.MEDUSA_FF_MEDUSA_V2 === "true"
}
// The API_BASE_URL is set in the .env file. It is the base URL of your Next.js app.
const API_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:8000"
/**
* Fetches a product by handle, using the Medusa API or the Medusa Product Module, depending on the feature flag.
* @param handle (string) - The handle of the product to retrieve
* @returns (array) - An array of products (should only be one)
*/
export async function getProductByHandle(
handle: string
): Promise<{ products: PricedProduct[] }> {
if (MEDUSA_V2_ENABLED) {
const data = await fetch(`${API_BASE_URL}/api/products/${handle}`)
.then((res) => res.json())
.catch((err) => {
throw err
})
return data
}
const { products } = await medusaRequest("GET", "/products", {
query: {
handle,
},
})
.then((res) => res.body)
.catch((err) => {
throw err
})
return {
products,
}
}
/**
* Fetches a list of products, using the Medusa API or the Medusa Product Module, depending on the feature flag.
* @param pageParam (number) - The offset of the products to retrieve
* @param queryParams (object) - The query parameters to pass to the API
* @returns 'response' (object) - An object containing the products and the next page offset
* @returns 'nextPage' (number) - The offset of the next page of products
*/
export async function getProductsList({
pageParam = 0,
queryParams,
}: {
pageParam?: number
queryParams: StoreGetProductsParams
}): Promise<{
response: { products: PricedProduct[]; count: number }
nextPage: number
}> {
const limit = queryParams.limit || 12
if (MEDUSA_V2_ENABLED) {
const params = new URLSearchParams(queryParams as Record<string, string>)
const { products, count, nextPage } = await fetch(
`${API_BASE_URL}/api/products?limit=${limit}&offset=${pageParam}&${params.toString()}`,
{
next: {
tags: ["products"],
},
}
).then((res) => res.json())
return {
response: { products, count },
nextPage,
}
}
const { products, count, nextPage } = await medusaRequest(
"GET",
"/products",
{
query: {
limit,
offset: pageParam,
...queryParams,
},
}
)
.then((res) => res.body)
.catch((err) => {
throw err
})
return {
response: { products, count },
nextPage,
}
}
/**
* Fetches a list of collections, using the Medusa API or the Medusa Product Module, depending on the feature flag.
* @param offset (number) - The offset of the collections to retrieve (default: 0
* @returns collections (array) - An array of collections
* @returns count (number) - The total number of collections
*/
export async function getCollectionsList(
offset: number = 0,
limit: number = 100
): Promise<{ collections: ProductCollection[]; count: number }> {
if (MEDUSA_V2_ENABLED) {
const { collections, count } = await fetch(
`${API_BASE_URL}/api/collections?offset=${offset}&limit=${limit}`,
{
next: {
tags: ["collections"],
},
}
)
.then((res) => res.json())
.catch((err) => {
throw err
})
return {
collections,
count,
}
}
const { collections, count } = await medusaRequest("GET", "/collections", {
query: {
offset,
limit,
},
})
.then((res) => res.body)
.catch((err) => {
throw err
})
return {
collections,
count,
}
}
/**
* Fetches a collection by handle, using the Medusa API or the Medusa Product Module, depending on the feature flag.
* @param handle (string) - The handle of the collection to retrieve
* @returns collections (array) - An array of collections (should only be one)
* @returns response (object) - An object containing the products and the number of products in the collection
* @returns nextPage (number) - The offset of the next page of products
*/
export async function getCollectionByHandle(handle: string): Promise<{
collections: ProductCollection[]
response: { products: Product[]; count: number }
nextPage: number
}> {
if (MEDUSA_V2_ENABLED) {
const data = await fetch(`${API_BASE_URL}/api/collections/${handle}`)
.then((res) => res.json())
.catch((err) => {
throw err
})
return data
}
const data = await medusaRequest("GET", "/collections", {
query: {
handle: [handle],
},
})
.then((res) => res.body)
.catch((err) => {
throw err
})
return data
}
/**
* Fetches a list of products in a collection, using the Medusa API or the Medusa Product Module, depending on the feature flag.
* @param pageParam (number) - The offset of the products to retrieve
* @param handle (string) - The handle of the collection to retrieve
* @param cartId (string) - The ID of the cart
* @returns response (object) - An object containing the products and the number of products in the collection
* @returns nextPage (number) - The offset of the next page of products
*/
export async function getProductsByCollectionHandle({
pageParam = 0,
limit = 100,
handle,
cartId,
currencyCode,
}: {
pageParam?: number
handle: string
limit?: number
cartId?: string
currencyCode?: string
}): Promise<{
response: { products: PricedProduct[]; count: number }
nextPage: number
}> {
if (MEDUSA_V2_ENABLED) {
const { response, nextPage } = await fetch(
`${API_BASE_URL}/api/collections/${handle}?currency_code=${currencyCode}&page=${pageParam.toString()}&limit=${limit}`
)
.then((res) => res.json())
.catch((err) => {
throw err
})
return {
response,
nextPage,
}
}
const { id } = await getCollectionByHandle(handle).then(
(res) => res.collections[0]
)
const { response, nextPage } = await getProductsList({
pageParam,
queryParams: { collection_id: [id], cart_id: cartId, limit },
})
.then((res) => res)
.catch((err) => {
throw err
})
return {
response,
nextPage,
}
}
/**
* Fetches a list of categories, using the Medusa API or the Medusa Product Module, depending on the feature flag.
* @param offset (number) - The offset of the categories to retrieve (default: 0
* @param limit (number) - The limit of the categories to retrieve (default: 100)
* @returns product_categories (array) - An array of product_categories
* @returns count (number) - The total number of categories
* @returns nextPage (number) - The offset of the next page of categories
*/
export async function getCategoriesList(
offset: number = 0,
limit?: number
): Promise<{
product_categories: ProductCategoryWithChildren[]
count: number
}> {
if (MEDUSA_V2_ENABLED) {
const { product_categories, count } = await fetch(
`${API_BASE_URL}/api/categories?offset=${offset}&limit=${limit}`,
{
next: {
tags: ["categories"],
},
}
)
.then((res) => res.json())
.catch((err) => {
throw err
})
return {
product_categories,
count,
}
}
const { product_categories, count } = await medusaRequest(
"GET",
"/product-categories",
{
query: {
offset,
limit,
},
}
)
.then((res) => res.body)
.catch((err) => {
throw err
})
return {
product_categories,
count,
}
}
/**
* Fetches a category by handle, using the Medusa API or the Medusa Product Module, depending on the feature flag.
* @param categoryHandle (string) - The handle of the category to retrieve
* @returns collections (array) - An array of categories (should only be one)
* @returns response (object) - An object containing the products and the number of products in the category
* @returns nextPage (number) - The offset of the next page of products
*/
export async function getCategoryByHandle(categoryHandle: string[]): Promise<{
product_categories: ProductCategoryWithChildren[]
}> {
if (MEDUSA_V2_ENABLED) {
const data = await fetch(
`${API_BASE_URL}/api/categories/${categoryHandle}`,
{
next: {
tags: ["categories"],
},
}
)
.then((res) => res.json())
.catch((err) => {
throw err
})
return data
}
const handles = categoryHandle.map((handle: string, index: number) =>
categoryHandle.slice(0, index + 1).join("/")
)
const product_categories = [] as ProductCategoryWithChildren[]
for (const handle of handles) {
await medusaRequest("GET", "/product-categories", {
query: {
handle,
},
})
.then(({ body }) => {
product_categories.push(body.product_categories[0])
})
.catch((err) => {
throw err
})
}
return {
product_categories,
}
}
/**
* Fetches a list of products in a collection, using the Medusa API or the Medusa Product Module, depending on the feature flag.
* @param pageParam (number) - The offset of the products to retrieve
* @param handle (string) - The handle of the collection to retrieve
* @param cartId (string) - The ID of the cart
* @returns response (object) - An object containing the products and the number of products in the collection
* @returns nextPage (number) - The offset of the next page of products
*/
export async function getProductsByCategoryHandle({
pageParam = 0,
handle,
cartId,
currencyCode,
}: {
pageParam?: number
handle: string
cartId?: string
currencyCode?: string
}): Promise<{
response: { products: PricedProduct[]; count: number }
nextPage: number
}> {
if (MEDUSA_V2_ENABLED) {
const { response, nextPage } = await fetch(
`${API_BASE_URL}/api/categories/${handle}?currency_code=${currencyCode}&page=${pageParam.toString()}`,
{
next: {
tags: ["categories"],
},
}
)
.then((res) => res.json())
.catch((err) => {
throw err
})
return {
response,
nextPage,
}
}
const { id } = await getCategoryByHandle([handle]).then(
(res) => res.product_categories[0]
)
const { response, nextPage } = await getProductsList({
pageParam,
queryParams: { category_id: [id], cart_id: cartId },
})
.then((res) => res)
.catch((err) => {
throw err
})
return {
response,
nextPage,
}
}
export async function getHomepageProducts({
collectionHandles,
currencyCode,
}: {
collectionHandles?: string[]
currencyCode: string
}) {
const collectionProductsMap = new Map<string, PricedProduct[]>()
const { collections } = await getCollectionsList(0, 3)
if (!collectionHandles) {
collectionHandles = collections.map((collection) => collection.handle)
}
for (const handle of collectionHandles) {
const products = await getProductsByCollectionHandle({
handle,
currencyCode,
limit: 3,
})
collectionProductsMap.set(handle, products.response.products)
}
return collectionProductsMap
}

@ -0,0 +1,28 @@
import { useRegions } from "medusa-react"
import { useMemo } from "react"
type CountryOption = {
country: string
region: string
label: string
}
const useCountryOptions = () => {
const { regions } = useRegions()
const options: CountryOption[] | undefined = useMemo(() => {
return regions
?.map((r) => {
return r.countries.map((c) => ({
country: c.iso_2,
region: r.id,
label: c.display_name,
}))
})
.flat()
}, [regions])
return options
}
export default useCountryOptions

@ -0,0 +1,34 @@
import { IS_BROWSER } from "@lib/constants"
import { useEffect, useState } from "react"
const getWidth = () => {
if (IS_BROWSER) {
return (
window.innerWidth ||
document.documentElement.clientWidth ||
document.body.clientWidth
)
}
return 0
}
const useCurrentWidth = () => {
const [width, setWidth] = useState(getWidth())
useEffect(() => {
const resizeListener = () => {
setWidth(getWidth())
}
window.addEventListener("resize", resizeListener)
return () => {
window.removeEventListener("resize", resizeListener)
}
}, [])
return width
}
export default useCurrentWidth

@ -0,0 +1,17 @@
import { useEffect, useState } from "react"
const useDebounce = <T extends unknown>(value: T, delay: number) => {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}
export default useDebounce

@ -0,0 +1,75 @@
import { LineItem } from "@medusajs/medusa"
import omit from "lodash/omit"
import { useCart, useProducts } from "medusa-react"
import { useMemo } from "react"
/**
* A hook that returns an array of enriched line items.
* If you pass an array of line items, it will return those line items with enriched data.
* Otherwise it will return the line items from the current cart.
*/
const useEnrichedLineItems = (lineItems?: LineItem[], cartId?: string) => {
const { cart } = useCart()
const queryParams = useMemo(() => {
if (lineItems) {
return {
id: lineItems.map((lineItem) => lineItem.variant.product_id),
cart_id: cartId,
}
}
return {
id: cart?.items.map((lineItem) => lineItem.variant.product_id),
cart_id: cart?.id,
}
}, [lineItems, cart?.items, cart?.id, cartId])
const { products } = useProducts(queryParams, {
enabled: !!lineItems || !!cart?.items?.length,
keepPreviousData: true,
})
// We enrich the line items with the product and variant information
const items = useMemo(() => {
const currItems = lineItems || cart?.items
if (!currItems?.length || !products) {
return []
}
const enrichedItems: Omit<LineItem, "beforeInsert">[] = []
for (const item of currItems) {
const product = products.find((p) => p.id === item.variant.product_id)
if (!product) {
enrichedItems.push(item)
return
}
const variant = product.variants.find((v) => v.id === item.variant_id)
if (!variant) {
enrichedItems.push(item)
return
}
enrichedItems.push({
...item,
// @ts-ignore
variant: {
...variant,
// @ts-ignore
product: omit(product, "variants"),
},
})
}
return enrichedItems
}, [cart?.items, lineItems, products])
return items
}
export default useEnrichedLineItems

@ -0,0 +1,29 @@
import { RefObject, useEffect, useState } from "react"
export const useIntersection = (
element: RefObject<HTMLDivElement>,
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
}

@ -0,0 +1,133 @@
import { getProductsList, getCollectionsList } from "@lib/data"
import { getPercentageDiff } from "@lib/util/get-precentage-diff"
import { ProductCollection, Region } from "@medusajs/medusa"
import { PricedProduct } from "@medusajs/medusa/dist/types/pricing"
import { useQuery } from "@tanstack/react-query"
import { formatAmount, useCart } from "medusa-react"
import { ProductPreviewType } from "types/global"
import { CalculatedVariant } from "types/medusa"
type LayoutCollection = {
handle: string
title: string
}
const fetchCollectionData = async (): Promise<LayoutCollection[]> => {
let collections: ProductCollection[] = []
let offset = 0
let count = 1
do {
await getCollectionsList(offset)
.then((res) => res)
.then(({ collections: newCollections, count: newCount }) => {
collections = [...collections, ...newCollections]
count = newCount
offset = collections.length
})
.catch((_) => {
count = 0
})
} while (collections.length < count)
return collections.map((c) => ({
handle: c.handle,
title: c.title,
}))
}
export const useNavigationCollections = () => {
const queryResults = useQuery({
queryFn: fetchCollectionData,
queryKey: ["navigation_collections"],
staleTime: Infinity,
refetchOnWindowFocus: false,
})
return queryResults
}
const fetchFeaturedProducts = async (
cartId: string,
region: Region,
collectionId?: string
): Promise<ProductPreviewType[]> => {
const products: PricedProduct[] = await getProductsList({
pageParam: 0,
queryParams: {
limit: 3,
cart_id: cartId,
region_id: region.id,
currency_code: region.currency_code,
collection_id: collectionId ? [collectionId] : [],
},
})
.then((res) => res.response)
.then(({ products }) => products)
.catch((_) => [] as PricedProduct[])
return products
.filter((p) => !!p.variants)
.map((p) => {
const variants = p.variants as unknown as CalculatedVariant[]
const cheapestVariant = variants.reduce((acc, curr) => {
if (acc.calculated_price > curr.calculated_price) {
return curr
}
return acc
}, variants[0])
return {
id: p.id!,
title: p.title!,
handle: p.handle!,
thumbnail: p.thumbnail!,
price: cheapestVariant
? {
calculated_price: formatAmount({
amount: cheapestVariant.calculated_price,
region: region,
includeTaxes: false,
}),
original_price: formatAmount({
amount: cheapestVariant.original_price,
region: region,
includeTaxes: false,
}),
difference: getPercentageDiff(
cheapestVariant.original_price,
cheapestVariant.calculated_price
),
price_type: cheapestVariant.calculated_price_type,
}
: {
calculated_price: "N/A",
original_price: "N/A",
difference: "N/A",
price_type: "default",
},
}
})
}
export const useFeaturedProductsQuery = (collectionId?: string) => {
const { cart } = useCart()
const queryResults = useQuery(
["layout_featured_products", cart?.id, cart?.region, collectionId],
() =>
fetchFeaturedProducts(
cart?.id!,
cart?.region!,
collectionId && collectionId
),
{
enabled: !!cart?.id && !!cart?.region,
staleTime: Infinity,
refetchOnWindowFocus: false,
}
)
return queryResults
}

@ -0,0 +1,43 @@
import transformProductPreview from "@lib/util/transform-product-preview"
import { Region } from "@medusajs/medusa"
import { PricedProduct } from "@medusajs/medusa/dist/types/pricing"
import { useMemo } from "react"
import { InfiniteProductPage, ProductPreviewType } from "types/global"
import sortProducts from "@lib/util/sort-products"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
type UsePreviewProps<T> = {
pages?: T[]
region?: Region
sortBy?: SortOptions
}
const usePreviews = <T extends InfiniteProductPage>({
pages,
region,
sortBy,
}: UsePreviewProps<T>) => {
const previews: ProductPreviewType[] = useMemo(() => {
if (!pages || !region) {
return []
}
const products: PricedProduct[] = []
for (const page of pages) {
products.push(...page.response.products)
}
const transformedProducts = products.map((p) =>
transformProductPreview(p, region)
)
sortBy && sortProducts(transformedProducts, sortBy)
return transformedProducts
}, [pages, region, sortBy])
return previews
}
export default usePreviews

@ -0,0 +1,107 @@
import { formatAmount, useCart, useProducts } from "medusa-react"
import { useEffect, useMemo } from "react"
import { CalculatedVariant } from "types/medusa"
type useProductPriceProps = {
id: string
variantId?: string
}
const useProductPrice = ({ id, variantId }: useProductPriceProps) => {
const { cart } = useCart()
const { products, isLoading, isError, refetch } = useProducts(
{
id: id,
cart_id: cart?.id,
},
{ enabled: !!cart?.id && !!cart?.region_id }
)
useEffect(() => {
if (cart?.region_id) {
refetch()
}
}, [cart?.region_id, refetch])
const product = products?.[0]
const getPercentageDiff = (original: number, calculated: number) => {
const diff = original - calculated
const decrease = (diff / original) * 100
return decrease.toFixed()
}
const cheapestPrice = useMemo(() => {
if (!product || !product.variants?.length || !cart?.region) {
return null
}
const variants = product.variants as unknown as CalculatedVariant[]
const cheapestVariant = variants.reduce((prev, curr) => {
return prev.calculated_price < curr.calculated_price ? prev : curr
})
return {
calculated_price: formatAmount({
amount: cheapestVariant.calculated_price,
region: cart.region,
includeTaxes: false,
}),
original_price: formatAmount({
amount: cheapestVariant.original_price,
region: cart.region,
includeTaxes: false,
}),
price_type: cheapestVariant.calculated_price_type,
percentage_diff: getPercentageDiff(
cheapestVariant.original_price,
cheapestVariant.calculated_price
),
}
}, [product, cart?.region])
const variantPrice = useMemo(() => {
if (!product || !variantId || !cart?.region) {
return null
}
const variant = product.variants.find(
(v) => v.id === variantId || v.sku === variantId
) as unknown as CalculatedVariant
if (!variant) {
return null
}
return {
calculated_price: formatAmount({
amount: variant.calculated_price,
region: cart.region,
includeTaxes: false,
}),
original_price: formatAmount({
amount: variant.original_price,
region: cart.region,
includeTaxes: false,
}),
price_type: variant.calculated_price_type,
percentage_diff: getPercentageDiff(
variant.original_price,
variant.calculated_price
),
}
}, [product, variantId, cart?.region])
return {
product,
cheapestPrice,
variantPrice,
isLoading,
isError,
}
}
export default useProductPrice

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

@ -0,0 +1,76 @@
const MEDUSA_API_KEY = process.env.NEXT_PUBLIC_MEDUSA_API_KEY || ""
const REVALIDATE_WINDOW = process.env.REVALIDATE_WINDOW || 60 * 30 // 30 minutes
const ENDPOINT =
process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL || "http://localhost:9000"
export default async function medusaRequest(
method: string,
path = "",
payload?: {
query?: Record<string, any>
body?: Record<string, any>
}
) {
const options: RequestInit = {
method,
headers: {
"Content-Type": "application/json",
"x-publishable-key": MEDUSA_API_KEY,
},
next: {
revalidate: parseInt(REVALIDATE_WINDOW.toString()),
tags: ["medusa_request"],
},
}
if (payload?.body) {
options.body = JSON.stringify(payload.body)
}
if (payload?.query) {
const params = objectToURLSearchParams(payload.query!).toString()
path = `${path}?${params}`
}
const limit = payload?.query?.limit || 100
const offset = payload?.query?.offset || 0
try {
const result = await fetch(`${ENDPOINT}/store${path}`, options)
const body = await result.json()
if (body.errors) {
throw body.errors[0]
}
const nextPage = offset + limit
body.nextPage = body.count > nextPage ? nextPage : null
return {
status: result.status,
ok: result.ok,
body,
}
} catch (error: any) {
throw {
error: error.message,
}
}
}
function objectToURLSearchParams(obj: Record<string, any>): URLSearchParams {
const params = new URLSearchParams()
for (const key in obj) {
if (Array.isArray(obj[key])) {
obj[key].forEach((value: any) => {
params.append(`${key}[]`, value)
})
} else {
params.append(key, obj[key])
}
}
return params
}

@ -0,0 +1,25 @@
import { instantMeiliSearch } from "@meilisearch/instant-meilisearch"
const endpoint =
process.env.NEXT_PUBLIC_SEARCH_ENDPOINT || "http://127.0.0.1:7700"
const apiKey = process.env.NEXT_PUBLIC_SEARCH_API_KEY || "test_key"
export const searchClient = instantMeiliSearch(endpoint, apiKey)
export const SEARCH_INDEX_NAME =
process.env.NEXT_PUBLIC_INDEX_NAME || "products"
// If you want to use Algolia instead then uncomment the following lines, and delete the above lines
// you should also install algoliasearch - yarn add algoliasearch
// import algoliasearch from "algoliasearch/lite"
// const appId = process.env.NEXT_PUBLIC_SEARCH_APP_ID || "test_app_id"
// const apiKey = process.env.NEXT_PUBLIC_SEARCH_API_KEY || "test_key"
// export const searchClient = algoliasearch(appId, apiKey)
// export const SEARCH_INDEX_NAME =
// process.env.NEXT_PUBLIC_INDEX_NAME || "products"

@ -0,0 +1,5 @@
import { ProductVariant } from "@medusajs/medusa"
export const canBuy = (variant: Omit<ProductVariant, "beforeInsert">) => {
return variant.inventory_quantity > 0 || variant.allow_backorder === true
}

@ -0,0 +1,8 @@
import { ProductDTO } from "@medusajs/types/dist/product/common"
export default function filterProductsByStatus(
products: ProductDTO[],
status: string
) {
return products.filter((product) => product.status === status)
}

@ -0,0 +1,11 @@
import { medusaClient } from "../config"
export const getCollectionIds = async (): Promise<string[]> => {
const data = await medusaClient.collections
.list({ limit: 100 })
.then(({ collections }) => {
return collections.map(({ id }) => id)
})
return data
}

@ -0,0 +1,25 @@
/**
* Calculates the number of spooky skeletons to show while an infinite scroll is loading the next page.
* Per default we fetch 12 products per page, so we need to calculate the number of skeletons to show,
* if the remaing products are less than 12.
*/
import { InfiniteProductPage } from "types/global"
const getNumberOfSkeletons = (pages?: InfiniteProductPage[]) => {
if (!pages) {
return 0
}
const count = pages[pages.length - 1].response.count
const retrieved =
count - pages.reduce((acc, curr) => acc + curr.response.products.length, 0)
if (count - retrieved < 12) {
return count - retrieved
}
return 12
}
export default getNumberOfSkeletons

@ -0,0 +1,6 @@
export const getPercentageDiff = (original: number, calculated: number) => {
const diff = original - calculated
const decrease = (diff / original) * 100
return decrease.toFixed()
}

@ -0,0 +1,41 @@
import { IPricingModuleService, CalculatedPriceSetDTO } from "@medusajs/types"
type Props = {
products: any[]
currency_code: string
pricingService: IPricingModuleService
}
/**
* Calculates the prices for a list of products, given a currency code.
* @param products List of products to calculate prices for.
* @param currency_code Currency code to calculate prices in.
* @param pricingService Pricing service to use for calculating prices.
* @returns The list of products with prices calculated.
*/
export async function getPricesByPriceSetId({
products,
currency_code,
pricingService,
}: Props): Promise<typeof products> {
for (const product of products) {
for (const variant of product.variants) {
const priceSetId = variant.price.price_set.id
const [price] = (await pricingService.calculatePrices(
{ id: [priceSetId] },
{
context: { currency_code },
}
)) as unknown as CalculatedPriceSetDTO[]
delete variant.price
if (!price) continue
variant.price = price
variant.calculated_price = price.amount
}
}
return products
}

@ -0,0 +1,17 @@
import { medusaClient } from "../config"
export const getProductHandles = async (): Promise<string[]> => {
const products = await medusaClient.products
.list({ limit: 25 })
.then(({ products }) => products)
const handles: string[] = []
for (const product of products) {
if (product.handle) {
handles.push(product.handle)
}
}
return handles
}

@ -0,0 +1,65 @@
import medusaRequest from "@lib/medusa-fetch"
import { ProductDTO, ProductVariantDTO } from "@medusajs/types/dist/product"
/**
* Wourkaround to get variant prices until we release a dedicated pricing module
* @param data Array of product objects (with variants) to get prices for
* @param cartId (Optional) cart id to get region-specific prices
*/
export default async function getPrices(
data: ProductDTO[],
cartId?: string,
regionId?: string
) {
if (!data || !data.length) {
return []
}
// Map of variant id to variant object
const variantsById = new Map<string, Record<string, any>>()
const productIds = data.map((p) => p.id)
const query = {
id: productIds,
expand: "variants,variants.prices,variants.options",
} as Record<string, any>
if (cartId) {
query.cart_id = cartId
}
if (regionId) {
query.region_id = regionId
}
// Get all products with variants and prices from Medusa API
const productsWithVariants = await medusaRequest("GET", `/products`, {
query,
}).then((res) => res.body.products)
// Map all variants by id
for (const product of productsWithVariants) {
for (const variant of product.variants) {
variantsById.set(variant.id, variant)
}
}
// Map prices to variants
const output = data.map((product) => {
const variants = product.variants.map((v) => {
const variant = variantsById.get(v.id)
if (!variant) {
return v
}
return variant as ProductVariantDTO
})
product.variants = variants
return product
})
// Return products with prices
return output
}

@ -0,0 +1,7 @@
export const handleError = (error: Error) => {
if (process.env.NODE_ENV === "development") {
console.error(error)
}
// TODO: user facing error message
}

@ -0,0 +1,6 @@
/**
* A funtion that does nothing.
*/
const noop = () => {}
export default noop

@ -0,0 +1,2 @@
export const onlyUnique = (value: unknown, index: number, self: unknown[]) =>
self.indexOf(value) === index

@ -0,0 +1,91 @@
import { MoneyAmount } from "@medusajs/medusa"
import { formatAmount } from "medusa-react"
import { Region, Variant } from "types/medusa"
export const findCheapestRegionPrice = (
variants: Variant[],
regionId: string
) => {
const regionPrices = variants.reduce((acc, v) => {
if (!v.prices) {
return acc
}
const price = v.prices.find((p) => p.region_id === regionId)
if (price) {
acc.push(price)
}
return acc
}, [] as MoneyAmount[])
if (!regionPrices.length) {
return undefined
}
//find the price with the lowest amount in regionPrices
const cheapestPrice = regionPrices.reduce((acc, p) => {
if (acc.amount > p.amount) {
return p
}
return acc
})
return cheapestPrice
}
export const findCheapestCurrencyPrice = (
variants: Variant[],
currencyCode: string
) => {
const currencyPrices = variants.reduce((acc, v) => {
if (!v.prices) {
return acc
}
const price = v.prices.find((p) => p.currency_code === currencyCode)
if (price) {
acc.push(price)
}
return acc
}, [] as MoneyAmount[])
if (!currencyPrices.length) {
return undefined
}
//find the price with the lowest amount in currencyPrices
const cheapestPrice = currencyPrices.reduce((acc, p) => {
if (acc.amount > p.amount) {
return p
}
return acc
})
return cheapestPrice
}
export const findCheapestPrice = (variants: Variant[], region: Region) => {
const { id, currency_code } = region
let cheapestPrice = findCheapestRegionPrice(variants, id)
if (!cheapestPrice) {
cheapestPrice = findCheapestCurrencyPrice(variants, currency_code)
}
if (cheapestPrice) {
return formatAmount({
amount: cheapestPrice.amount,
region: region,
})
}
// if we can't find any price that matches the current region,
// either by id or currency, then the product is not available in
// the current region
return "Not available in your region"
}

@ -0,0 +1 @@
export const emailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/g

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

@ -0,0 +1,42 @@
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
import { ProductPreviewType } from "types/global"
const stripCurrency = (price: string) => {
return parseFloat(price.replace(/[^0-9.]/g, ""))
}
const sortProducts = (products: ProductPreviewType[], sortBy: SortOptions) => {
if (sortBy === "price_asc") {
return products.sort((a, b) => {
if (!a.price?.calculated_price || !b.price?.calculated_price) return 0
return (
stripCurrency(a.price?.calculated_price) -
stripCurrency(b.price?.calculated_price)
)
})
}
if (sortBy === "price_desc") {
return products.sort((a, b) => {
if (!a.price?.calculated_price || !b.price?.calculated_price) return 0
return (
stripCurrency(b.price?.calculated_price) -
stripCurrency(a.price?.calculated_price)
)
})
}
if (sortBy === "created_at") {
return products.sort((a, b) => {
if (!a.created_at || !b.created_at) return 0
return new Date(b.created_at).valueOf() - new Date(a.created_at).valueOf()
})
}
return products
}
export default sortProducts

@ -0,0 +1,53 @@
import { getPercentageDiff } from "@lib/util/get-precentage-diff"
import { Region } from "@medusajs/medusa"
import { PricedProduct } from "@medusajs/medusa/dist/types/pricing"
import { formatAmount } from "medusa-react"
import { ProductPreviewType } from "types/global"
import { CalculatedVariant } from "types/medusa"
const transformProductPreview = (
product: PricedProduct,
region: Region
): ProductPreviewType => {
const variants = product.variants as unknown as CalculatedVariant[]
let cheapestVariant = undefined
if (variants?.length > 0) {
cheapestVariant = variants.reduce((acc, curr) => {
if (acc.calculated_price > curr.calculated_price) {
return curr
}
return acc
}, variants[0])
}
return {
id: product.id!,
title: product.title!,
handle: product.handle!,
thumbnail: product.thumbnail!,
created_at: product.created_at,
price: cheapestVariant
? {
calculated_price: formatAmount({
amount: cheapestVariant.calculated_price,
region: region,
includeTaxes: false,
}),
original_price: formatAmount({
amount: cheapestVariant.original_price,
region: region,
includeTaxes: false,
}),
difference: getPercentageDiff(
cheapestVariant.original_price,
cheapestVariant.calculated_price
),
price_type: cheapestVariant.calculated_price_type,
}
: undefined,
}
}
export default transformProductPreview

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

@ -0,0 +1,86 @@
import { useAccount } from "@lib/context/account-context"
import ChevronDown from "@modules/common/icons/chevron-down"
import clsx from "clsx"
import Link from "next/link"
import { usePathname } from "next/navigation"
const AccountNav = () => {
const route = usePathname()
const { handleLogout } = useAccount()
return (
<div>
<div className="small:hidden">
{route !== "/account" && (
<Link
href="/account"
className="flex items-center gap-x-2 text-small-regular py-2"
>
<>
<ChevronDown className="transform rotate-90" />
<span>Account</span>
</>
</Link>
)}
</div>
<div className="hidden small:block">
<div>
<div className="py-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!}>
Overview
</AccountNavLink>
</li>
<li>
<AccountNavLink href="/account/profile" route={route!}>
Profile
</AccountNavLink>
</li>
<li>
<AccountNavLink href="/account/addresses" route={route!}>
Addresses
</AccountNavLink>
</li>
<li>
<AccountNavLink href="/account/orders" route={route!}>
Orders
</AccountNavLink>
</li>
<li className="text-grey-700">
<button type="button" onClick={handleLogout}>
Log out
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
)
}
type AccountNavLinkProps = {
href: string
route: string
children: React.ReactNode
}
const AccountNavLink = ({ href, route, children }: AccountNavLinkProps) => {
const active = route === href
return (
<Link
href={href}
className={clsx("text-gray-700", {
"text-gray-900 font-semibold": active,
})}
>
<>{children}</>
</Link>
)
}
export default AccountNav

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

@ -0,0 +1,198 @@
import { medusaClient } from "@lib/config"
import { useAccount } from "@lib/context/account-context"
import useToggleState from "@lib/hooks/use-toggle-state"
import CountrySelect from "@modules/checkout/components/country-select"
import { Button, Heading } from "@medusajs/ui"
import Input from "@modules/common/components/input"
import Modal from "@modules/common/components/modal"
import { Plus } from "@medusajs/icons"
import { Spinner } from "@medusajs/icons"
import React, { useState } from "react"
import { useForm } from "react-hook-form"
type FormValues = {
first_name: string
last_name: string
city: string
country_code: string
postal_code: string
province?: string
address_1: string
address_2?: string
phone?: string
company?: string
}
const AddAddress: React.FC = () => {
const { state, open, close } = useToggleState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | undefined>(undefined)
const { refetchCustomer } = useAccount()
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<FormValues>()
const handleClose = () => {
reset({
first_name: "",
last_name: "",
city: "",
country_code: "",
postal_code: "",
address_1: "",
address_2: "",
company: "",
phone: "",
province: "",
})
close()
}
const submit = handleSubmit(async (data: FormValues) => {
setSubmitting(true)
setError(undefined)
const payload = {
first_name: data.first_name,
last_name: data.last_name,
company: data.company || "",
address_1: data.address_1,
address_2: data.address_2 || "",
city: data.city,
country_code: data.country_code,
province: data.province || "",
postal_code: data.postal_code,
phone: data.phone || "",
metadata: {},
}
medusaClient.customers.addresses
.addAddress({ address: payload })
.then(() => {
setSubmitting(false)
refetchCustomer()
handleClose()
})
.catch(() => {
setSubmitting(false)
setError("Failed to add address, please try again.")
})
})
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}
>
<span className="text-base-semi">New address</span>
<Plus />
</button>
<Modal isOpen={state} close={handleClose}>
<Modal.Title>
<Heading className="mb-2">Add address</Heading>
</Modal.Title>
<Modal.Body>
<div className="flex flex-col gap-y-2">
<div className="grid grid-cols-2 gap-x-2">
<Input
label="First name"
{...register("first_name", {
required: "First name is required",
})}
required
errors={errors}
autoComplete="given-name"
/>
<Input
label="Last name"
{...register("last_name", {
required: "Last name is required",
})}
required
errors={errors}
autoComplete="family-name"
/>
</div>
<Input label="Company" {...register("company")} errors={errors} />
<Input
label="Address"
{...register("address_1", {
required: "Address is required",
})}
required
errors={errors}
autoComplete="address-line1"
/>
<Input
label="Apartment, suite, etc."
{...register("address_2")}
errors={errors}
autoComplete="address-line2"
/>
<div className="grid grid-cols-[144px_1fr] gap-x-2">
<Input
label="Postal code"
{...register("postal_code", {
required: "Postal code is required",
})}
required
errors={errors}
autoComplete="postal-code"
/>
<Input
label="City"
{...register("city", {
required: "City is required",
})}
errors={errors}
required
autoComplete="locality"
/>
</div>
<Input
label="Province / State"
{...register("province")}
errors={errors}
autoComplete="address-level1"
/>
<CountrySelect
{...register("country_code", { required: true })}
autoComplete="country"
/>
<Input
label="Phone"
{...register("phone")}
errors={errors}
autoComplete="phone"
/>
</div>
{error && (
<div className="text-rose-500 text-small-regular py-2">{error}</div>
)}
</Modal.Body>
<Modal.Footer>
<div className="flex gap-3 mt-4">
<Button
variant="secondary"
onClick={handleClose}
disabled={submitting}
>
Cancel
</Button>
<Button className="min-h-0" onClick={submit} isLoading={submitting}>
Save
</Button>
</div>
</Modal.Footer>
</Modal>
</>
)
}
export default AddAddress

@ -0,0 +1,247 @@
import { medusaClient } from "@lib/config"
import { useAccount } from "@lib/context/account-context"
import useToggleState from "@lib/hooks/use-toggle-state"
import { Address } from "@medusajs/medusa"
import CountrySelect from "@modules/checkout/components/country-select"
import { Button, Heading, Text } from "@medusajs/ui"
import { PencilSquare as Edit, Trash } from "@medusajs/icons"
import Input from "@modules/common/components/input"
import Modal from "@modules/common/components/modal"
import Spinner from "@modules/common/icons/spinner"
import clsx from "clsx"
import React, { useState } from "react"
import { useForm } from "react-hook-form"
type FormValues = {
first_name: string
last_name: string
city: string
country_code: string
postal_code: string
province?: string
address_1: string
address_2?: string
phone?: string
company?: string
}
type EditAddressProps = {
address: Address
isActive?: boolean
}
const EditAddress: React.FC<EditAddressProps> = ({
address,
isActive = false,
}) => {
const { state, open, close } = useToggleState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | undefined>(undefined)
const { refetchCustomer } = useAccount()
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
defaultValues: {
first_name: address.first_name || undefined,
last_name: address.last_name || undefined,
city: address.city || undefined,
address_1: address.address_1 || undefined,
address_2: address.address_2 || undefined,
country_code: address.country_code || undefined,
postal_code: address.postal_code || undefined,
phone: address.phone || undefined,
company: address.company || undefined,
province: address.province || undefined,
},
})
const submit = handleSubmit(async (data: FormValues) => {
setSubmitting(true)
setError(undefined)
const payload = {
first_name: data.first_name,
last_name: data.last_name,
company: data.company || "Personal",
address_1: data.address_1,
address_2: data.address_2 || "",
city: data.city,
country_code: data.country_code,
province: data.province || "",
postal_code: data.postal_code,
phone: data.phone || "None",
metadata: {},
}
medusaClient.customers.addresses
.updateAddress(address.id, payload)
.then(() => {
setSubmitting(false)
refetchCustomer()
close()
})
.catch(() => {
setSubmitting(false)
setError("Failed to update address, please try again.")
})
})
const removeAddress = () => {
medusaClient.customers.addresses.deleteAddress(address.id).then(() => {
refetchCustomer()
})
}
return (
<>
<div
className={clsx(
"border rounded-rounded p-5 min-h-[220px] h-full w-full flex flex-col justify-between transition-colors",
{
"border-gray-900": isActive,
}
)}
>
<div className="flex flex-col">
<Heading className="text-left text-base-semi">
{address.first_name} {address.last_name}
</Heading>
{address.company && (
<Text className="txt-compact-small text-gray-700">
{address.company}
</Text>
)}
<Text 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>
</Text>
</div>
<div className="flex items-center gap-x-4">
<button
className="text-small-regular text-gray-700 flex items-center gap-x-2"
onClick={open}
>
<Edit />
Edit
</button>
<button
className="text-small-regular text-gray-700 flex items-center gap-x-2"
onClick={removeAddress}
>
<Trash />
Remove
</button>
</div>
</div>
<Modal isOpen={state} close={close}>
<Modal.Title>
<Heading className="mb-2">Edit address</Heading>
</Modal.Title>
<Modal.Body>
<div className="grid grid-cols-1 gap-y-2">
<div className="grid grid-cols-2 gap-x-2">
<Input
label="First name"
{...register("first_name", {
required: "First name is required",
})}
required
errors={errors}
autoComplete="given-name"
/>
<Input
label="Last name"
{...register("last_name", {
required: "Last name is required",
})}
required
errors={errors}
autoComplete="family-name"
/>
</div>
<Input label="Company" {...register("company")} errors={errors} />
<Input
label="Address"
{...register("address_1", {
required: "Address is required",
})}
required
errors={errors}
autoComplete="address-line1"
/>
<Input
label="Apartment, suite, etc."
{...register("address_2")}
errors={errors}
autoComplete="address-line2"
/>
<div className="grid grid-cols-[144px_1fr] gap-x-2">
<Input
label="Postal code"
{...register("postal_code", {
required: "Postal code is required",
})}
required
errors={errors}
autoComplete="postal-code"
/>
<Input
label="City"
{...register("city", {
required: "City is required",
})}
errors={errors}
required
autoComplete="locality"
/>
</div>
<Input
label="Province / State"
{...register("province")}
errors={errors}
autoComplete="address-level1"
/>
<CountrySelect
{...register("country_code", { required: true })}
autoComplete="country"
/>
<Input
label="Phone"
{...register("phone")}
errors={errors}
autoComplete="phone"
/>
</div>
{error && (
<div className="text-rose-500 text-small-regular py-2">{error}</div>
)}
</Modal.Body>
<Modal.Footer>
<div className="flex gap-3 mt-4">
<Button variant="secondary" onClick={close} disabled={submitting}>
Cancel
</Button>
<Button className="min-h-0" onClick={submit} isLoading={submitting}>
Save
</Button>
</div>
</Modal.Footer>
</Modal>
</>
)
}
export default EditAddress

@ -0,0 +1,33 @@
import React from "react"
type DetailProps = {
title: string
}
type SubDetailProps = {
title?: string
}
const Detail: React.FC<DetailProps> & {
SubDetail: React.FC<SubDetailProps>
} = ({ title, children }) => {
return (
<div>
<h2 className="text-large-semi mb-2">{title}</h2>
<div className="flex flex-col gap-y-4 text-small-regular">{children}</div>
</div>
)
}
const SubDetail: React.FC<SubDetailProps> = ({ title, children }) => {
return (
<div className="flex flex-col">
{title && <span className="text-base-semi">{title}</span>}
<div className="text-small-regular">{children}</div>
</div>
)
}
Detail.SubDetail = SubDetail
export default Detail

@ -0,0 +1,18 @@
import React from "react"
const EditButton: React.FC<React.HTMLAttributes<HTMLButtonElement>> = (
props
) => {
return (
<div>
<button
className="underline text-small-regular text-gray-700 mt-2"
{...props}
>
Edit
</button>
</div>
)
}
export default EditButton

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

Loading…
Cancel
Save