Initial commit
commit
cd4d75d0c4
@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: ["next/core-web-vitals"]
|
||||
};
|
||||
@ -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,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"
|
||||
@ -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))
|
||||
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…
Reference in New Issue