Initial commit: backend, storefront, vendor-panel added

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

17
storefront/.env.template Normal file
View File

@@ -0,0 +1,17 @@
# Your Medusa backend, should be updated to where you are hosting your server. Remember to update CORS settings for your server. See https://docs.medusajs.com/learn/configurations/medusa-config#httpstorecors
MEDUSA_BACKEND_URL=http://localhost:9000
# Your publishable key that can be attached to sales channels. See - https://docs.medusajs.com/resources/storefront-development/publishable-api-keys
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_test
# Your store URL, should be updated to where you are hosting your storefront.
NEXT_PUBLIC_BASE_URL=http://localhost:8000
# Your preferred default region. When middleware cannot determine the user region from the "x-vercel-country" header, the default region will be used. ISO-2 lowercase format.
NEXT_PUBLIC_DEFAULT_REGION=us
# Your Stripe public key. See  https://docs.medusajs.com/resources/commerce-modules/payment/payment-provider/stripe
NEXT_PUBLIC_STRIPE_KEY=
# Your Next.js revalidation secret. See https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#on-demand-revalidation
REVALIDATE_SECRET=supersecret

3
storefront/.eslintrc.js Normal file
View File

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

View File

@@ -0,0 +1,71 @@
name: Bug report for the Medusa Next.js Starter
description: File a bug report.
title: "[Bug]: "
labels: ["status: needs triaging", "bug"]
body:
- type: markdown
attributes:
value: "## System information"
- type: markdown
attributes:
value: |
The system information will help us reproduce the issue in the same environment
- type: textarea
attributes:
label: Package.json file
description: Copy/paste the contents of the `package.json` file. No need to use backticks
placeholder: No need to use markdown backticks. Just copy/paste the contents of the file
render: JSON
validations:
required: true
- type: input
attributes:
label: Node.js version
description: Copy/paste the output of `node -v` command.
placeholder: v21.0.0
validations:
required: true
- type: input
attributes:
label: Operating system name and version
validations:
required: true
- type: input
attributes:
label: Browser name
- type: markdown
attributes:
value: "## Describe the issue"
- type: markdown
attributes:
value: |
Please explain your issue in-depth along with the relevant screenshots and code snippets
- type: textarea
attributes:
label: What happended?
placeholder: A clear and concise description of what the bug is
validations:
required: true
- type: textarea
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
attributes:
label: Actual behavior
validations:
required: true
- type: markdown
attributes:
value: "## Reproduction"
- type: markdown
attributes:
value: |
Providing a reproduction repo allows us to quickly validate the issue and get back to you.
- type: input
attributes:
label: Link to reproduction repo
description: Please reproduce the issue in isolation and share it as a Github repo with us
validations:
required: true

57
storefront/.gitignore vendored Normal file
View File

@@ -0,0 +1,57 @@
# 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
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth

8
storefront/.prettierrc Normal file
View File

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

5
storefront/.yarnrc.yml Normal file
View File

@@ -0,0 +1,5 @@
compressionLevel: mixed
enableGlobalCache: false
nodeLinker: node-modules

21
storefront/LICENSE Normal file
View File

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

125
storefront/README.md Normal file
View File

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

View File

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

5
storefront/next-env.d.ts vendored Normal file
View File

@@ -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/app/api-reference/config/typescript for more information.

View File

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

43
storefront/next.config.js Normal file
View File

@@ -0,0 +1,43 @@
const checkEnvVariables = require("./check-env-variables")
checkEnvVariables()
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
reactStrictMode: true,
logging: {
fetches: {
fullUrl: true,
},
},
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: 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",
},
],
},
}
module.exports = nextConfig

64
storefront/package.json Normal file
View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,30 @@
import { retrieveCart } from "@lib/data/cart"
import { retrieveCustomer } from "@lib/data/customer"
import PaymentWrapper from "@modules/checkout/components/payment-wrapper"
import CheckoutForm from "@modules/checkout/templates/checkout-form"
import CheckoutSummary from "@modules/checkout/templates/checkout-summary"
import { Metadata } from "next"
import { notFound } from "next/navigation"
export const metadata: Metadata = {
title: "Checkout",
}
export default async function Checkout() {
const cart = await retrieveCart()
if (!cart) {
return notFound()
}
const customer = await retrieveCustomer()
return (
<div className="grid grid-cols-1 small:grid-cols-[1fr_416px] content-container gap-x-40 py-12">
<PaymentWrapper cart={cart}>
<CheckoutForm cart={cart} customer={customer} />
</PaymentWrapper>
<CheckoutSummary cart={cart} />
</div>
)
}

View File

@@ -0,0 +1,43 @@
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import ChevronDown from "@modules/common/icons/chevron-down"
import MedusaCTA from "@modules/layout/components/medusa-cta"
export default function CheckoutLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="w-full bg-white relative small:min-h-screen">
<div className="h-16 bg-white border-b ">
<nav className="flex h-full items-center content-container justify-between">
<LocalizedClientLink
href="/cart"
className="text-small-semi text-ui-fg-base flex items-center gap-x-2 uppercase flex-1 basis-0"
data-testid="back-to-cart-link"
>
<ChevronDown className="rotate-90" size={16} />
<span className="mt-px hidden small:block txt-compact-plus text-ui-fg-subtle hover:text-ui-fg-base ">
Back to shopping cart
</span>
<span className="mt-px block small:hidden txt-compact-plus text-ui-fg-subtle hover:text-ui-fg-base">
Back
</span>
</LocalizedClientLink>
<LocalizedClientLink
href="/"
className="txt-compact-xlarge-plus text-ui-fg-subtle hover:text-ui-fg-base uppercase"
data-testid="store-link"
>
Medusa Store
</LocalizedClientLink>
<div className="flex-1 basis-0" />
</nav>
</div>
<div className="relative" data-testid="checkout-container">{children}</div>
<div className="py-4 w-full flex items-center justify-center">
<MedusaCTA />
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,38 @@
import { Metadata } from "next"
import { notFound } from "next/navigation"
import AddressBook from "@modules/account/components/address-book"
import { getRegion } from "@lib/data/regions"
import { retrieveCustomer } from "@lib/data/customer"
export const metadata: Metadata = {
title: "Addresses",
description: "View your addresses",
}
export default async function Addresses(props: {
params: Promise<{ countryCode: string }>
}) {
const params = await props.params
const { countryCode } = params
const customer = await retrieveCustomer()
const region = await getRegion(countryCode)
if (!customer || !region) {
notFound()
}
return (
<div className="w-full" data-testid="addresses-page-wrapper">
<div className="mb-8 flex flex-col gap-y-4">
<h1 className="text-2xl-semi">Shipping Addresses</h1>
<p className="text-base-regular">
View and update your shipping addresses, you can add as many as you
like. Saving your addresses will make them available during checkout.
</p>
</div>
<AddressBook customer={customer} region={region} />
</div>
)
}

View File

@@ -0,0 +1,9 @@
import Spinner from "@modules/common/icons/spinner"
export default function Loading() {
return (
<div className="flex items-center justify-center w-full h-full text-ui-fg-base">
<Spinner size={36} />
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { retrieveOrder } from "@lib/data/orders"
import OrderDetailsTemplate from "@modules/order/templates/order-details-template"
import { Metadata } from "next"
import { notFound } from "next/navigation"
type Props = {
params: Promise<{ id: string }>
}
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params
const order = await retrieveOrder(params.id).catch(() => null)
if (!order) {
notFound()
}
return {
title: `Order #${order.display_id}`,
description: `View your order`,
}
}
export default async function OrderDetailPage(props: Props) {
const params = await props.params
const order = await retrieveOrder(params.id).catch(() => null)
if (!order) {
notFound()
}
return <OrderDetailsTemplate order={order} />
}

View File

@@ -0,0 +1,37 @@
import { Metadata } from "next"
import OrderOverview from "@modules/account/components/order-overview"
import { notFound } from "next/navigation"
import { listOrders } from "@lib/data/orders"
import Divider from "@modules/common/components/divider"
import TransferRequestForm from "@modules/account/components/transfer-request-form"
export const metadata: Metadata = {
title: "Orders",
description: "Overview of your previous orders.",
}
export default async function Orders() {
const orders = await listOrders()
if (!orders) {
notFound()
}
return (
<div className="w-full" data-testid="orders-page-wrapper">
<div className="mb-8 flex flex-col gap-y-4">
<h1 className="text-2xl-semi">Orders</h1>
<p className="text-base-regular">
View your previous orders and their status. You can also create
returns or exchanges for your orders if needed.
</p>
</div>
<div>
<OrderOverview orders={orders} />
<Divider className="my-16" />
<TransferRequestForm />
</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { Metadata } from "next"
import Overview from "@modules/account/components/overview"
import { notFound } from "next/navigation"
import { retrieveCustomer } from "@lib/data/customer"
import { listOrders } from "@lib/data/orders"
export const metadata: Metadata = {
title: "Account",
description: "Overview of your account activity.",
}
export default async function OverviewTemplate() {
const customer = await retrieveCustomer().catch(() => null)
const orders = (await listOrders().catch(() => null)) || null
if (!customer) {
notFound()
}
return <Overview customer={customer} orders={orders} />
}

View File

@@ -0,0 +1,54 @@
import { Metadata } from "next"
import ProfilePhone from "@modules/account//components/profile-phone"
import ProfileBillingAddress from "@modules/account/components/profile-billing-address"
import ProfileEmail from "@modules/account/components/profile-email"
import ProfileName from "@modules/account/components/profile-name"
import ProfilePassword from "@modules/account/components/profile-password"
import { notFound } from "next/navigation"
import { listRegions } from "@lib/data/regions"
import { retrieveCustomer } from "@lib/data/customer"
export const metadata: Metadata = {
title: "Profile",
description: "View and edit your Medusa Store profile.",
}
export default async function Profile() {
const customer = await retrieveCustomer()
const regions = await listRegions()
if (!customer || !regions) {
notFound()
}
return (
<div className="w-full" data-testid="profile-page-wrapper">
<div className="mb-8 flex flex-col gap-y-4">
<h1 className="text-2xl-semi">Profile</h1>
<p className="text-base-regular">
View and update your profile information, including your name, email,
and phone number. You can also update your billing address, or change
your password.
</p>
</div>
<div className="flex flex-col gap-y-8 w-full">
<ProfileName customer={customer} />
<Divider />
<ProfileEmail customer={customer} />
<Divider />
<ProfilePhone customer={customer} />
<Divider />
{/* <ProfilePassword customer={customer} />
<Divider /> */}
<ProfileBillingAddress customer={customer} regions={regions} />
</div>
</div>
)
}
const Divider = () => {
return <div className="w-full h-px bg-gray-200" />
}
;``

View File

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

View File

@@ -0,0 +1,20 @@
import { retrieveCustomer } from "@lib/data/customer"
import { Toaster } from "@medusajs/ui"
import AccountLayout from "@modules/account/templates/account-layout"
export default async function AccountPageLayout({
dashboard,
login,
}: {
dashboard?: React.ReactNode
login?: React.ReactNode
}) {
const customer = await retrieveCustomer().catch(() => null)
return (
<AccountLayout customer={customer}>
{customer ? dashboard : login}
<Toaster />
</AccountLayout>
)
}

View File

@@ -0,0 +1,9 @@
import Spinner from "@modules/common/icons/spinner"
export default function Loading() {
return (
<div className="flex items-center justify-center w-full h-full text-ui-fg-base">
<Spinner size={36} />
</div>
)
}

View File

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

View File

@@ -0,0 +1,21 @@
import { Metadata } from "next"
import InteractiveLink from "@modules/common/components/interactive-link"
export const metadata: Metadata = {
title: "404",
description: "Something went wrong",
}
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-64px)]">
<h1 className="text-2xl-semi text-ui-fg-base">Page not found</h1>
<p className="text-small-regular text-ui-fg-base">
The cart you tried to access does not exist. Clear your cookies and try
again.
</p>
<InteractiveLink href="/">Go to frontpage</InteractiveLink>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { retrieveCart } from "@lib/data/cart"
import { retrieveCustomer } from "@lib/data/customer"
import CartTemplate from "@modules/cart/templates"
import { Metadata } from "next"
import { notFound } from "next/navigation"
export const metadata: Metadata = {
title: "Cart",
description: "View your cart",
}
export default async function Cart() {
const cart = await retrieveCart().catch((error) => {
console.error(error)
return notFound()
})
const customer = await retrieveCustomer()
return <CartTemplate cart={cart} customer={customer} />
}

View File

@@ -0,0 +1,85 @@
import { Metadata } from "next"
import { notFound } from "next/navigation"
import { getCategoryByHandle, listCategories } from "@lib/data/categories"
import { listRegions } from "@lib/data/regions"
import { StoreRegion } from "@medusajs/types"
import CategoryTemplate from "@modules/categories/templates"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
type Props = {
params: Promise<{ category: string[]; countryCode: string }>
searchParams: Promise<{
sortBy?: SortOptions
page?: string
}>
}
export async function generateStaticParams() {
const product_categories = await listCategories()
if (!product_categories) {
return []
}
const countryCodes = await listRegions().then((regions: StoreRegion[]) =>
regions?.map((r) => r.countries?.map((c) => c.iso_2)).flat()
)
const categoryHandles = product_categories.map(
(category: any) => category.handle
)
const staticParams = countryCodes
?.map((countryCode: string | undefined) =>
categoryHandles.map((handle: any) => ({
countryCode,
category: [handle],
}))
)
.flat()
return staticParams
}
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params
try {
const productCategory = await getCategoryByHandle(params.category)
const title = productCategory.name + " | Medusa Store"
const description = productCategory.description ?? `${title} category.`
return {
title: `${title} | Medusa Store`,
description,
alternates: {
canonical: `${params.category.join("/")}`,
},
}
} catch (error) {
notFound()
}
}
export default async function CategoryPage(props: Props) {
const searchParams = await props.searchParams
const params = await props.params
const { sortBy, page } = searchParams
const productCategory = await getCategoryByHandle(params.category)
if (!productCategory) {
notFound()
}
return (
<CategoryTemplate
category={productCategory}
sortBy={sortBy}
page={page}
countryCode={params.countryCode}
/>
)
}

View File

@@ -0,0 +1,90 @@
import { Metadata } from "next"
import { notFound } from "next/navigation"
import { getCollectionByHandle, listCollections } from "@lib/data/collections"
import { listRegions } from "@lib/data/regions"
import { StoreCollection, StoreRegion } from "@medusajs/types"
import CollectionTemplate from "@modules/collections/templates"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
type Props = {
params: Promise<{ handle: string; countryCode: string }>
searchParams: Promise<{
page?: string
sortBy?: SortOptions
}>
}
export const PRODUCT_LIMIT = 12
export async function generateStaticParams() {
const { collections } = await listCollections({
fields: "*products",
})
if (!collections) {
return []
}
const countryCodes = await listRegions().then(
(regions: StoreRegion[]) =>
regions
?.map((r) => r.countries?.map((c) => c.iso_2))
.flat()
.filter(Boolean) as string[]
)
const collectionHandles = collections.map(
(collection: StoreCollection) => collection.handle
)
const staticParams = countryCodes
?.map((countryCode: string) =>
collectionHandles.map((handle: string | undefined) => ({
countryCode,
handle,
}))
)
.flat()
return staticParams
}
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params
const collection = await getCollectionByHandle(params.handle)
if (!collection) {
notFound()
}
const metadata = {
title: `${collection.title} | Medusa Store`,
description: `${collection.title} collection`,
} as Metadata
return metadata
}
export default async function CollectionPage(props: Props) {
const searchParams = await props.searchParams
const params = await props.params
const { sortBy, page } = searchParams
const collection = await getCollectionByHandle(params.handle).then(
(collection: StoreCollection) => collection
)
if (!collection) {
notFound()
}
return (
<CollectionTemplate
collection={collection}
page={page}
sortBy={sortBy}
countryCode={params.countryCode}
/>
)
}

View File

@@ -0,0 +1,45 @@
import { Metadata } from "next"
import { listCartOptions, retrieveCart } from "@lib/data/cart"
import { retrieveCustomer } from "@lib/data/customer"
import { getBaseURL } from "@lib/util/env"
import { StoreCartShippingOption } from "@medusajs/types"
import CartMismatchBanner from "@modules/layout/components/cart-mismatch-banner"
import Footer from "@modules/layout/templates/footer"
import Nav from "@modules/layout/templates/nav"
import FreeShippingPriceNudge from "@modules/shipping/components/free-shipping-price-nudge"
export const metadata: Metadata = {
metadataBase: new URL(getBaseURL()),
}
export default async function PageLayout(props: { children: React.ReactNode }) {
const customer = await retrieveCustomer()
const cart = await retrieveCart()
let shippingOptions: StoreCartShippingOption[] = []
if (cart) {
const { shipping_options } = await listCartOptions()
shippingOptions = shipping_options
}
return (
<>
<Nav />
{customer && cart && (
<CartMismatchBanner customer={customer} cart={cart} />
)}
{cart && (
<FreeShippingPriceNudge
variant="popup"
cart={cart}
shippingOptions={shippingOptions}
/>
)}
{props.children}
<Footer />
</>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
import { retrieveOrder } from "@lib/data/orders"
import OrderCompletedTemplate from "@modules/order/templates/order-completed-template"
import { Metadata } from "next"
import { notFound } from "next/navigation"
type Props = {
params: Promise<{ id: string }>
}
export const metadata: Metadata = {
title: "Order Confirmed",
description: "You purchase was successful",
}
export default async function OrderConfirmedPage(props: Props) {
const params = await props.params
const order = await retrieveOrder(params.id).catch(() => null)
if (!order) {
return notFound()
}
return <OrderCompletedTemplate order={order} />
}

View File

@@ -0,0 +1,41 @@
import { acceptTransferRequest } from "@lib/data/orders"
import { Heading, Text } from "@medusajs/ui"
import TransferImage from "@modules/order/components/transfer-image"
export default async function TransferPage({
params,
}: {
params: { id: string; token: string }
}) {
const { id, token } = params
const { success, error } = await acceptTransferRequest(id, token)
return (
<div className="flex flex-col gap-y-4 items-start w-2/5 mx-auto mt-10 mb-20">
<TransferImage />
<div className="flex flex-col gap-y-6">
{success && (
<>
<Heading level="h1" className="text-xl text-zinc-900">
Order transfered!
</Heading>
<Text className="text-zinc-600">
Order {id} has been successfully transfered to the new owner.
</Text>
</>
)}
{!success && (
<>
<Text className="text-zinc-600">
There was an error accepting the transfer. Please try again.
</Text>
{error && (
<Text className="text-red-500">Error message: {error}</Text>
)}
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { declineTransferRequest } from "@lib/data/orders"
import { Heading, Text } from "@medusajs/ui"
import TransferImage from "@modules/order/components/transfer-image"
export default async function TransferPage({
params,
}: {
params: { id: string; token: string }
}) {
const { id, token } = params
const { success, error } = await declineTransferRequest(id, token)
return (
<div className="flex flex-col gap-y-4 items-start w-2/5 mx-auto mt-10 mb-20">
<TransferImage />
<div className="flex flex-col gap-y-6">
{success && (
<>
<Heading level="h1" className="text-xl text-zinc-900">
Order transfer declined!
</Heading>
<Text className="text-zinc-600">
Transfer of order {id} has been successfully declined.
</Text>
</>
)}
{!success && (
<>
<Text className="text-zinc-600">
There was an error declining the transfer. Please try again.
</Text>
{error && (
<Text className="text-red-500">Error message: {error}</Text>
)}
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import { Heading, Text } from "@medusajs/ui"
import TransferActions from "@modules/order/components/transfer-actions"
import TransferImage from "@modules/order/components/transfer-image"
export default async function TransferPage({
params,
}: {
params: { id: string; token: string }
}) {
const { id, token } = params
return (
<div className="flex flex-col gap-y-4 items-start w-2/5 mx-auto mt-10 mb-20">
<TransferImage />
<div className="flex flex-col gap-y-6">
<Heading level="h1" className="text-xl text-zinc-900">
Transfer request for order {id}
</Heading>
<Text className="text-zinc-600">
You&#39;ve received a request to transfer ownership of your order ({id}).
If you agree to this request, you can approve the transfer by clicking
the button below.
</Text>
<div className="w-full h-px bg-zinc-200" />
<Text className="text-zinc-600">
If you accept, the new owner will take over all responsibilities and
permissions associated with this order.
</Text>
<Text className="text-zinc-600">
If you do not recognize this request or wish to retain ownership, no
further action is required.
</Text>
<div className="w-full h-px bg-zinc-200" />
<TransferActions id={id} token={token} />
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { Metadata } from "next"
import FeaturedProducts from "@modules/home/components/featured-products"
import Hero from "@modules/home/components/hero"
import { listCollections } from "@lib/data/collections"
import { getRegion } from "@lib/data/regions"
export const metadata: Metadata = {
title: "Medusa Next.js Starter Template",
description:
"A performant frontend ecommerce starter template with Next.js 15 and Medusa.",
}
export default async function Home(props: {
params: Promise<{ countryCode: string }>
}) {
const params = await props.params
const { countryCode } = params
const region = await getRegion(countryCode)
const { collections } = await listCollections({
fields: "id, handle, title",
})
if (!collections || !region) {
return null
}
return (
<>
<Hero />
<div className="py-12">
<ul className="flex flex-col gap-x-6">
<FeaturedProducts collections={collections} region={region} />
</ul>
</div>
</>
)
}

View File

@@ -0,0 +1,106 @@
import { Metadata } from "next"
import { notFound } from "next/navigation"
import { listProducts } from "@lib/data/products"
import { getRegion, listRegions } from "@lib/data/regions"
import ProductTemplate from "@modules/products/templates"
type Props = {
params: Promise<{ countryCode: string; handle: string }>
}
export async function generateStaticParams() {
try {
const countryCodes = await listRegions().then((regions) =>
regions?.map((r) => r.countries?.map((c) => c.iso_2)).flat()
)
if (!countryCodes) {
return []
}
const promises = countryCodes.map(async (country) => {
const { response } = await listProducts({
countryCode: country,
queryParams: { limit: 100, fields: "handle" },
})
return {
country,
products: response.products,
}
})
const countryProducts = await Promise.all(promises)
return countryProducts
.flatMap((countryData) =>
countryData.products.map((product) => ({
countryCode: countryData.country,
handle: product.handle,
}))
)
.filter((param) => param.handle)
} catch (error) {
console.error(
`Failed to generate static paths for product pages: ${
error instanceof Error ? error.message : "Unknown error"
}.`
)
return []
}
}
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params
const { handle } = params
const region = await getRegion(params.countryCode)
if (!region) {
notFound()
}
const product = await listProducts({
countryCode: params.countryCode,
queryParams: { handle },
}).then(({ response }) => response.products[0])
if (!product) {
notFound()
}
return {
title: `${product.title} | Medusa Store`,
description: `${product.title}`,
openGraph: {
title: `${product.title} | Medusa Store`,
description: `${product.title}`,
images: product.thumbnail ? [product.thumbnail] : [],
},
}
}
export default async function ProductPage(props: Props) {
const params = await props.params
const region = await getRegion(params.countryCode)
if (!region) {
notFound()
}
const pricedProduct = await listProducts({
countryCode: params.countryCode,
queryParams: { handle: params.handle },
}).then(({ response }) => response.products[0])
if (!pricedProduct) {
notFound()
}
return (
<ProductTemplate
product={pricedProduct}
region={region}
countryCode={params.countryCode}
/>
)
}

View File

@@ -0,0 +1,33 @@
import { Metadata } from "next"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
import StoreTemplate from "@modules/store/templates"
export const metadata: Metadata = {
title: "Store",
description: "Explore all of our products.",
}
type Params = {
searchParams: Promise<{
sortBy?: SortOptions
page?: string
}>
params: Promise<{
countryCode: string
}>
}
export default async function StorePage(props: Params) {
const params = await props.params;
const searchParams = await props.searchParams;
const { sortBy, page } = searchParams
return (
<StoreTemplate
sortBy={sortBy}
page={page}
countryCode={params.countryCode}
/>
)
}

View File

@@ -0,0 +1,17 @@
import { getBaseURL } from "@lib/util/env"
import { Metadata } from "next"
import "styles/globals.css"
export const metadata: Metadata = {
metadataBase: new URL(getBaseURL()),
}
export default function RootLayout(props: { children: React.ReactNode }) {
return (
<html lang="en" data-mode="light">
<body>
<main className="relative">{props.children}</main>
</body>
</html>
)
}

View File

@@ -0,0 +1,30 @@
import { ArrowUpRightMini } from "@medusajs/icons"
import { Text } from "@medusajs/ui"
import { Metadata } from "next"
import Link from "next/link"
export const metadata: Metadata = {
title: "404",
description: "Something went wrong",
}
export default function NotFound() {
return (
<div className="flex flex-col gap-4 items-center justify-center min-h-[calc(100vh-64px)]">
<h1 className="text-2xl-semi text-ui-fg-base">Page not found</h1>
<p className="text-small-regular text-ui-fg-base">
The page you tried to access does not exist.
</p>
<Link
className="flex gap-x-1 items-center group"
href="/"
>
<Text className="text-ui-fg-interactive">Go to frontpage</Text>
<ArrowUpRightMini
className="group-hover:rotate-45 ease-in-out duration-150"
color="var(--fg-interactive)"
/>
</Link>
</div>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

View File

@@ -0,0 +1,14 @@
import Medusa from "@medusajs/js-sdk"
// Defaults to standard port for Medusa server
let MEDUSA_BACKEND_URL = "http://localhost:9000"
if (process.env.MEDUSA_BACKEND_URL) {
MEDUSA_BACKEND_URL = process.env.MEDUSA_BACKEND_URL
}
export const sdk = new Medusa({
baseUrl: MEDUSA_BACKEND_URL,
debug: process.env.NODE_ENV === "development",
publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,
})

View File

@@ -0,0 +1,68 @@
import React from "react"
import { CreditCard } from "@medusajs/icons"
import Ideal from "@modules/common/icons/ideal"
import Bancontact from "@modules/common/icons/bancontact"
import PayPal from "@modules/common/icons/paypal"
/* Map of payment provider_id to their title and icon. Add in any payment providers you want to use. */
export const paymentInfoMap: Record<
string,
{ title: string; icon: React.JSX.Element }
> = {
pp_stripe_stripe: {
title: "Credit card",
icon: <CreditCard />,
},
"pp_stripe-ideal_stripe": {
title: "iDeal",
icon: <Ideal />,
},
"pp_stripe-bancontact_stripe": {
title: "Bancontact",
icon: <Bancontact />,
},
pp_paypal_paypal: {
title: "PayPal",
icon: <PayPal />,
},
pp_system_default: {
title: "Manual Payment",
icon: <CreditCard />,
},
// Add more payment providers here
}
// This only checks if it is native stripe for card payments, it ignores the other stripe-based providers
export const isStripe = (providerId?: string) => {
return providerId?.startsWith("pp_stripe_")
}
export const isPaypal = (providerId?: string) => {
return providerId?.startsWith("pp_paypal")
}
export const isManual = (providerId?: string) => {
return providerId?.startsWith("pp_system_default")
}
// Add currencies that don't need to be divided by 100
export const noDivisionCurrencies = [
"krw",
"jpy",
"vnd",
"clp",
"pyg",
"xaf",
"xof",
"bif",
"djf",
"gnf",
"kmf",
"mga",
"rwf",
"xpf",
"htg",
"vuv",
"xag",
"xdr",
"xau",
]

View File

@@ -0,0 +1,34 @@
"use client"
import React, { createContext, useContext } from "react"
interface ModalContext {
close: () => void
}
const ModalContext = createContext<ModalContext | null>(null)
interface ModalProviderProps {
children?: React.ReactNode
close: () => void
}
export const ModalProvider = ({ children, close }: ModalProviderProps) => {
return (
<ModalContext.Provider
value={{
close,
}}
>
{children}
</ModalContext.Provider>
)
}
export const useModal = () => {
const context = useContext(ModalContext)
if (context === null) {
throw new Error("useModal must be used within a ModalProvider")
}
return context
}

View File

@@ -0,0 +1,470 @@
"use server"
import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { HttpTypes } from "@medusajs/types"
import { revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import {
getAuthHeaders,
getCacheOptions,
getCacheTag,
getCartId,
removeCartId,
setCartId,
} from "./cookies"
import { getRegion } from "./regions"
/**
* Retrieves a cart by its ID. If no ID is provided, it will use the cart ID from the cookies.
* @param cartId - optional - The ID of the cart to retrieve.
* @returns The cart object if found, or null if not found.
*/
export async function retrieveCart(cartId?: string) {
const id = cartId || (await getCartId())
if (!id) {
return null
}
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("carts")),
}
return await sdk.client
.fetch<HttpTypes.StoreCartResponse>(`/store/carts/${id}`, {
method: "GET",
query: {
fields:
"*items, *region, *items.product, *items.variant, *items.thumbnail, *items.metadata, +items.total, *promotions, +shipping_methods.name",
},
headers,
next,
cache: "force-cache",
})
.then(({ cart }) => cart)
.catch(() => null)
}
export async function getOrSetCart(countryCode: string) {
const region = await getRegion(countryCode)
if (!region) {
throw new Error(`Region not found for country code: ${countryCode}`)
}
let cart = await retrieveCart()
const headers = {
...(await getAuthHeaders()),
}
if (!cart) {
const cartResp = await sdk.store.cart.create(
{ region_id: region.id },
{},
headers
)
cart = cartResp.cart
await setCartId(cart.id)
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
}
if (cart && cart?.region_id !== region.id) {
await sdk.store.cart.update(cart.id, { region_id: region.id }, {}, headers)
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
}
return cart
}
export async function updateCart(data: HttpTypes.StoreUpdateCart) {
const cartId = await getCartId()
if (!cartId) {
throw new Error("No existing cart found, please create one before updating")
}
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.cart
.update(cartId, data, {}, headers)
.then(async ({ cart }) => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
const fulfillmentCacheTag = await getCacheTag("fulfillment")
revalidateTag(fulfillmentCacheTag)
return cart
})
.catch(medusaError)
}
export async function addToCart({
variantId,
quantity,
countryCode,
}: {
variantId: string
quantity: number
countryCode: string
}) {
if (!variantId) {
throw new Error("Missing variant ID when adding to cart")
}
const cart = await getOrSetCart(countryCode)
if (!cart) {
throw new Error("Error retrieving or creating cart")
}
const headers = {
...(await getAuthHeaders()),
}
await sdk.store.cart
.createLineItem(
cart.id,
{
variant_id: variantId,
quantity,
},
{},
headers
)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
const fulfillmentCacheTag = await getCacheTag("fulfillment")
revalidateTag(fulfillmentCacheTag)
})
.catch(medusaError)
}
export async function updateLineItem({
lineId,
quantity,
}: {
lineId: string
quantity: number
}) {
if (!lineId) {
throw new Error("Missing lineItem ID when updating line item")
}
const cartId = await getCartId()
if (!cartId) {
throw new Error("Missing cart ID when updating line item")
}
const headers = {
...(await getAuthHeaders()),
}
await sdk.store.cart
.updateLineItem(cartId, lineId, { quantity }, {}, headers)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
const fulfillmentCacheTag = await getCacheTag("fulfillment")
revalidateTag(fulfillmentCacheTag)
})
.catch(medusaError)
}
export async function deleteLineItem(lineId: string) {
if (!lineId) {
throw new Error("Missing lineItem ID when deleting line item")
}
const cartId = await getCartId()
if (!cartId) {
throw new Error("Missing cart ID when deleting line item")
}
const headers = {
...(await getAuthHeaders()),
}
await sdk.store.cart
.deleteLineItem(cartId, lineId, headers)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
const fulfillmentCacheTag = await getCacheTag("fulfillment")
revalidateTag(fulfillmentCacheTag)
})
.catch(medusaError)
}
export async function setShippingMethod({
cartId,
shippingMethodId,
}: {
cartId: string
shippingMethodId: string
}) {
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.cart
.addShippingMethod(cartId, { option_id: shippingMethodId }, {}, headers)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
})
.catch(medusaError)
}
export async function initiatePaymentSession(
cart: HttpTypes.StoreCart,
data: HttpTypes.StoreInitializePaymentSession
) {
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.payment
.initiatePaymentSession(cart, data, {}, headers)
.then(async (resp) => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
return resp
})
.catch(medusaError)
}
export async function applyPromotions(codes: string[]) {
const cartId = await getCartId()
if (!cartId) {
throw new Error("No existing cart found")
}
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.cart
.update(cartId, { promo_codes: codes }, {}, headers)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
const fulfillmentCacheTag = await getCacheTag("fulfillment")
revalidateTag(fulfillmentCacheTag)
})
.catch(medusaError)
}
export async function applyGiftCard(code: string) {
// const cartId = getCartId()
// if (!cartId) return "No cartId cookie found"
// try {
// await updateCart(cartId, { gift_cards: [{ code }] }).then(() => {
// revalidateTag("cart")
// })
// } catch (error: any) {
// throw error
// }
}
export async function removeDiscount(code: string) {
// const cartId = getCartId()
// if (!cartId) return "No cartId cookie found"
// try {
// await deleteDiscount(cartId, code)
// revalidateTag("cart")
// } catch (error: any) {
// throw error
// }
}
export async function removeGiftCard(
codeToRemove: string,
giftCards: any[]
// giftCards: GiftCard[]
) {
// const cartId = getCartId()
// if (!cartId) return "No cartId cookie found"
// try {
// await updateCart(cartId, {
// gift_cards: [...giftCards]
// .filter((gc) => gc.code !== codeToRemove)
// .map((gc) => ({ code: gc.code })),
// }).then(() => {
// revalidateTag("cart")
// })
// } catch (error: any) {
// throw error
// }
}
export async function submitPromotionForm(
currentState: unknown,
formData: FormData
) {
const code = formData.get("code") as string
try {
await applyPromotions([code])
} catch (e: any) {
return e.message
}
}
// TODO: Pass a POJO instead of a form entity here
export async function setAddresses(currentState: unknown, formData: FormData) {
try {
if (!formData) {
throw new Error("No form data found when setting addresses")
}
const cartId = getCartId()
if (!cartId) {
throw new Error("No existing cart found when setting addresses")
}
const data = {
shipping_address: {
first_name: formData.get("shipping_address.first_name"),
last_name: formData.get("shipping_address.last_name"),
address_1: formData.get("shipping_address.address_1"),
address_2: "",
company: formData.get("shipping_address.company"),
postal_code: formData.get("shipping_address.postal_code"),
city: formData.get("shipping_address.city"),
country_code: formData.get("shipping_address.country_code"),
province: formData.get("shipping_address.province"),
phone: formData.get("shipping_address.phone"),
},
email: formData.get("email"),
} as any
const sameAsBilling = formData.get("same_as_billing")
if (sameAsBilling === "on") data.billing_address = data.shipping_address
if (sameAsBilling !== "on")
data.billing_address = {
first_name: formData.get("billing_address.first_name"),
last_name: formData.get("billing_address.last_name"),
address_1: formData.get("billing_address.address_1"),
address_2: "",
company: formData.get("billing_address.company"),
postal_code: formData.get("billing_address.postal_code"),
city: formData.get("billing_address.city"),
country_code: formData.get("billing_address.country_code"),
province: formData.get("billing_address.province"),
phone: formData.get("billing_address.phone"),
}
await updateCart(data)
} catch (e: any) {
return e.message
}
redirect(
`/${formData.get("shipping_address.country_code")}/checkout?step=delivery`
)
}
/**
* Places an order for a cart. If no cart ID is provided, it will use the cart ID from the cookies.
* @param cartId - optional - The ID of the cart to place an order for.
* @returns The cart object if the order was successful, or null if not.
*/
export async function placeOrder(cartId?: string) {
const id = cartId || (await getCartId())
if (!id) {
throw new Error("No existing cart found when placing an order")
}
const headers = {
...(await getAuthHeaders()),
}
const cartRes = await sdk.store.cart
.complete(id, {}, headers)
.then(async (cartRes) => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
return cartRes
})
.catch(medusaError)
if (cartRes?.type === "order") {
const countryCode =
cartRes.order.shipping_address?.country_code?.toLowerCase()
const orderCacheTag = await getCacheTag("orders")
revalidateTag(orderCacheTag)
removeCartId()
redirect(`/${countryCode}/order/${cartRes?.order.id}/confirmed`)
}
return cartRes.cart
}
/**
* Updates the countrycode param and revalidates the regions cache
* @param regionId
* @param countryCode
*/
export async function updateRegion(countryCode: string, currentPath: string) {
const cartId = await getCartId()
const region = await getRegion(countryCode)
if (!region) {
throw new Error(`Region not found for country code: ${countryCode}`)
}
if (cartId) {
await updateCart({ region_id: region.id })
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
}
const regionCacheTag = await getCacheTag("regions")
revalidateTag(regionCacheTag)
const productsCacheTag = await getCacheTag("products")
revalidateTag(productsCacheTag)
redirect(`/${countryCode}${currentPath}`)
}
export async function listCartOptions() {
const cartId = await getCartId()
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("shippingOptions")),
}
return await sdk.client.fetch<{
shipping_options: HttpTypes.StoreCartShippingOption[]
}>("/store/shipping-options", {
query: { cart_id: cartId },
next,
headers,
cache: "force-cache",
})
}

View File

@@ -0,0 +1,49 @@
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
import { getCacheOptions } from "./cookies"
export const listCategories = async (query?: Record<string, any>) => {
const next = {
...(await getCacheOptions("categories")),
}
const limit = query?.limit || 100
return sdk.client
.fetch<{ product_categories: HttpTypes.StoreProductCategory[] }>(
"/store/product-categories",
{
query: {
fields:
"*category_children, *products, *parent_category, *parent_category.parent_category",
limit,
...query,
},
next,
cache: "force-cache",
}
)
.then(({ product_categories }) => product_categories)
}
export const getCategoryByHandle = async (categoryHandle: string[]) => {
const handle = `${categoryHandle.join("/")}`
const next = {
...(await getCacheOptions("categories")),
}
return sdk.client
.fetch<HttpTypes.StoreProductCategoryListResponse>(
`/store/product-categories`,
{
query: {
fields: "*category_children, *products",
handle,
},
next,
cache: "force-cache",
}
)
.then(({ product_categories }) => product_categories[0])
}

View File

@@ -0,0 +1,59 @@
"use server"
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
import { getCacheOptions } from "./cookies"
export const retrieveCollection = async (id: string) => {
const next = {
...(await getCacheOptions("collections")),
}
return sdk.client
.fetch<{ collection: HttpTypes.StoreCollection }>(
`/store/collections/${id}`,
{
next,
cache: "force-cache",
}
)
.then(({ collection }) => collection)
}
export const listCollections = async (
queryParams: Record<string, string> = {}
): Promise<{ collections: HttpTypes.StoreCollection[]; count: number }> => {
const next = {
...(await getCacheOptions("collections")),
}
queryParams.limit = queryParams.limit || "100"
queryParams.offset = queryParams.offset || "0"
return sdk.client
.fetch<{ collections: HttpTypes.StoreCollection[]; count: number }>(
"/store/collections",
{
query: queryParams,
next,
cache: "force-cache",
}
)
.then(({ collections }) => ({ collections, count: collections.length }))
}
export const getCollectionByHandle = async (
handle: string
): Promise<HttpTypes.StoreCollection> => {
const next = {
...(await getCacheOptions("collections")),
}
return sdk.client
.fetch<HttpTypes.StoreCollectionListResponse>(`/store/collections`, {
query: { handle, fields: "*products" },
next,
cache: "force-cache",
})
.then(({ collections }) => collections[0])
}

View File

@@ -0,0 +1,89 @@
import "server-only"
import { cookies as nextCookies } from "next/headers"
export const getAuthHeaders = async (): Promise<
{ authorization: string } | {}
> => {
try {
const cookies = await nextCookies()
const token = cookies.get("_medusa_jwt")?.value
if (!token) {
return {}
}
return { authorization: `Bearer ${token}` }
} catch {
return {}
}
}
export const getCacheTag = async (tag: string): Promise<string> => {
try {
const cookies = await nextCookies()
const cacheId = cookies.get("_medusa_cache_id")?.value
if (!cacheId) {
return ""
}
return `${tag}-${cacheId}`
} catch (error) {
return ""
}
}
export const getCacheOptions = async (
tag: string
): Promise<{ tags: string[] } | {}> => {
if (typeof window !== "undefined") {
return {}
}
const cacheTag = await getCacheTag(tag)
if (!cacheTag) {
return {}
}
return { tags: [`${cacheTag}`] }
}
export const setAuthToken = async (token: string) => {
const cookies = await nextCookies()
cookies.set("_medusa_jwt", token, {
maxAge: 60 * 60 * 24 * 7,
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
})
}
export const removeAuthToken = async () => {
const cookies = await nextCookies()
cookies.set("_medusa_jwt", "", {
maxAge: -1,
})
}
export const getCartId = async () => {
const cookies = await nextCookies()
return cookies.get("_medusa_cart_id")?.value
}
export const setCartId = async (cartId: string) => {
const cookies = await nextCookies()
cookies.set("_medusa_cart_id", cartId, {
maxAge: 60 * 60 * 24 * 7,
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
})
}
export const removeCartId = async () => {
const cookies = await nextCookies()
cookies.set("_medusa_cart_id", "", {
maxAge: -1,
})
}

View File

@@ -0,0 +1,261 @@
"use server"
import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { HttpTypes } from "@medusajs/types"
import { revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import {
getAuthHeaders,
getCacheOptions,
getCacheTag,
getCartId,
removeAuthToken,
removeCartId,
setAuthToken,
} from "./cookies"
export const retrieveCustomer =
async (): Promise<HttpTypes.StoreCustomer | null> => {
const authHeaders = await getAuthHeaders()
if (!authHeaders) return null
const headers = {
...authHeaders,
}
const next = {
...(await getCacheOptions("customers")),
}
return await sdk.client
.fetch<{ customer: HttpTypes.StoreCustomer }>(`/store/customers/me`, {
method: "GET",
query: {
fields: "*orders",
},
headers,
next,
cache: "force-cache",
})
.then(({ customer }) => customer)
.catch(() => null)
}
export const updateCustomer = async (body: HttpTypes.StoreUpdateCustomer) => {
const headers = {
...(await getAuthHeaders()),
}
const updateRes = await sdk.store.customer
.update(body, {}, headers)
.then(({ customer }) => customer)
.catch(medusaError)
const cacheTag = await getCacheTag("customers")
revalidateTag(cacheTag)
return updateRes
}
export async function signup(_currentState: unknown, formData: FormData) {
const password = formData.get("password") as string
const customerForm = {
email: formData.get("email") as string,
first_name: formData.get("first_name") as string,
last_name: formData.get("last_name") as string,
phone: formData.get("phone") as string,
}
try {
const token = await sdk.auth.register("customer", "emailpass", {
email: customerForm.email,
password: password,
})
await setAuthToken(token as string)
const headers = {
...(await getAuthHeaders()),
}
const { customer: createdCustomer } = await sdk.store.customer.create(
customerForm,
{},
headers
)
const loginToken = await sdk.auth.login("customer", "emailpass", {
email: customerForm.email,
password,
})
await setAuthToken(loginToken as string)
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
await transferCart()
return createdCustomer
} catch (error: any) {
return error.toString()
}
}
export async function login(_currentState: unknown, formData: FormData) {
const email = formData.get("email") as string
const password = formData.get("password") as string
try {
await sdk.auth
.login("customer", "emailpass", { email, password })
.then(async (token) => {
await setAuthToken(token as string)
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
})
} catch (error: any) {
return error.toString()
}
try {
await transferCart()
} catch (error: any) {
return error.toString()
}
}
export async function signout(countryCode: string) {
await sdk.auth.logout()
await removeAuthToken()
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
await removeCartId()
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
redirect(`/${countryCode}/account`)
}
export async function transferCart() {
const cartId = await getCartId()
if (!cartId) {
return
}
const headers = await getAuthHeaders()
await sdk.store.cart.transferCart(cartId, {}, headers)
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
}
export const addCustomerAddress = async (
currentState: Record<string, unknown>,
formData: FormData
): Promise<any> => {
const isDefaultBilling = (currentState.isDefaultBilling as boolean) || false
const isDefaultShipping = (currentState.isDefaultShipping as boolean) || false
const address = {
first_name: formData.get("first_name") as string,
last_name: formData.get("last_name") as string,
company: formData.get("company") as string,
address_1: formData.get("address_1") as string,
address_2: formData.get("address_2") as string,
city: formData.get("city") as string,
postal_code: formData.get("postal_code") as string,
province: formData.get("province") as string,
country_code: formData.get("country_code") as string,
phone: formData.get("phone") as string,
is_default_billing: isDefaultBilling,
is_default_shipping: isDefaultShipping,
}
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.customer
.createAddress(address, {}, headers)
.then(async ({ customer }) => {
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
return { success: true, error: null }
})
.catch((err) => {
return { success: false, error: err.toString() }
})
}
export const deleteCustomerAddress = async (
addressId: string
): Promise<void> => {
const headers = {
...(await getAuthHeaders()),
}
await sdk.store.customer
.deleteAddress(addressId, headers)
.then(async () => {
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
return { success: true, error: null }
})
.catch((err) => {
return { success: false, error: err.toString() }
})
}
export const updateCustomerAddress = async (
currentState: Record<string, unknown>,
formData: FormData
): Promise<any> => {
const addressId =
(currentState.addressId as string) || (formData.get("addressId") as string)
if (!addressId) {
return { success: false, error: "Address ID is required" }
}
const address = {
first_name: formData.get("first_name") as string,
last_name: formData.get("last_name") as string,
company: formData.get("company") as string,
address_1: formData.get("address_1") as string,
address_2: formData.get("address_2") as string,
city: formData.get("city") as string,
postal_code: formData.get("postal_code") as string,
province: formData.get("province") as string,
country_code: formData.get("country_code") as string,
} as HttpTypes.StoreUpdateCustomerAddress
const phone = formData.get("phone") as string
if (phone) {
address.phone = phone
}
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.customer
.updateAddress(addressId, address, {}, headers)
.then(async () => {
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
return { success: true, error: null }
})
.catch((err) => {
return { success: false, error: err.toString() }
})
}

View File

@@ -0,0 +1,70 @@
"use server"
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
import { getAuthHeaders, getCacheOptions } from "./cookies"
export const listCartShippingMethods = async (cartId: string) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("fulfillment")),
}
return sdk.client
.fetch<HttpTypes.StoreShippingOptionListResponse>(
`/store/shipping-options`,
{
method: "GET",
query: {
cart_id: cartId,
fields:
"+service_zone.fulfllment_set.type,*service_zone.fulfillment_set.location.address",
},
headers,
next,
cache: "force-cache",
}
)
.then(({ shipping_options }) => shipping_options)
.catch(() => {
return null
})
}
export const calculatePriceForShippingOption = async (
optionId: string,
cartId: string,
data?: Record<string, unknown>
) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("fulfillment")),
}
const body = { cart_id: cartId, data }
if (data) {
body.data = data
}
return sdk.client
.fetch<{ shipping_option: HttpTypes.StoreCartShippingOption }>(
`/store/shipping-options/${optionId}/calculate`,
{
method: "POST",
body,
headers,
next,
}
)
.then(({ shipping_option }) => shipping_option)
.catch((e) => {
return null
})
}

View File

@@ -0,0 +1,9 @@
"use server"
import { cookies as nextCookies } from "next/headers"
import { redirect } from "next/navigation"
export async function resetOnboardingState(orderId: string) {
const cookies = await nextCookies()
cookies.set("_medusa_onboarding", "false", { maxAge: -1 })
redirect(`http://localhost:7001/a/orders/${orderId}`)
}

View File

@@ -0,0 +1,112 @@
"use server"
import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { getAuthHeaders, getCacheOptions } from "./cookies"
import { HttpTypes } from "@medusajs/types"
export const retrieveOrder = async (id: string) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("orders")),
}
return sdk.client
.fetch<HttpTypes.StoreOrderResponse>(`/store/orders/${id}`, {
method: "GET",
query: {
fields:
"*payment_collections.payments,*items,*items.metadata,*items.variant,*items.product",
},
headers,
next,
cache: "force-cache",
})
.then(({ order }) => order)
.catch((err) => medusaError(err))
}
export const listOrders = async (
limit: number = 10,
offset: number = 0,
filters?: Record<string, any>
) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("orders")),
}
return sdk.client
.fetch<HttpTypes.StoreOrderListResponse>(`/store/orders`, {
method: "GET",
query: {
limit,
offset,
order: "-created_at",
fields: "*items,+items.metadata,*items.variant,*items.product",
...filters,
},
headers,
next,
cache: "force-cache",
})
.then(({ orders }) => orders)
.catch((err) => medusaError(err))
}
export const createTransferRequest = async (
state: {
success: boolean
error: string | null
order: HttpTypes.StoreOrder | null
},
formData: FormData
): Promise<{
success: boolean
error: string | null
order: HttpTypes.StoreOrder | null
}> => {
const id = formData.get("order_id") as string
if (!id) {
return { success: false, error: "Order ID is required", order: null }
}
const headers = await getAuthHeaders()
return await sdk.store.order
.requestTransfer(
id,
{},
{
fields: "id, email",
},
headers
)
.then(({ order }) => ({ success: true, error: null, order }))
.catch((err) => ({ success: false, error: err.message, order: null }))
}
export const acceptTransferRequest = async (id: string, token: string) => {
const headers = await getAuthHeaders()
return await sdk.store.order
.acceptTransfer(id, { token }, {}, headers)
.then(({ order }) => ({ success: true, error: null, order }))
.catch((err) => ({ success: false, error: err.message, order: null }))
}
export const declineTransferRequest = async (id: string, token: string) => {
const headers = await getAuthHeaders()
return await sdk.store.order
.declineTransfer(id, { token }, {}, headers)
.then(({ order }) => ({ success: true, error: null, order }))
.catch((err) => ({ success: false, error: err.message, order: null }))
}

View File

@@ -0,0 +1,35 @@
"use server"
import { sdk } from "@lib/config"
import { getAuthHeaders, getCacheOptions } from "./cookies"
import { HttpTypes } from "@medusajs/types"
export const listCartPaymentMethods = async (regionId: string) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("payment_providers")),
}
return sdk.client
.fetch<HttpTypes.StorePaymentProviderListResponse>(
`/store/payment-providers`,
{
method: "GET",
query: { region_id: regionId },
headers,
next,
cache: "force-cache",
}
)
.then(({ payment_providers }) =>
payment_providers.sort((a, b) => {
return a.id > b.id ? 1 : -1
})
)
.catch(() => {
return null
})
}

View File

@@ -0,0 +1,136 @@
"use server"
import { sdk } from "@lib/config"
import { sortProducts } from "@lib/util/sort-products"
import { HttpTypes } from "@medusajs/types"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
import { getAuthHeaders, getCacheOptions } from "./cookies"
import { getRegion, retrieveRegion } from "./regions"
export const listProducts = async ({
pageParam = 1,
queryParams,
countryCode,
regionId,
}: {
pageParam?: number
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
countryCode?: string
regionId?: string
}): Promise<{
response: { products: HttpTypes.StoreProduct[]; count: number }
nextPage: number | null
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
}> => {
if (!countryCode && !regionId) {
throw new Error("Country code or region ID is required")
}
const limit = queryParams?.limit || 12
const _pageParam = Math.max(pageParam, 1)
const offset = (_pageParam === 1) ? 0 : (_pageParam - 1) * limit;
let region: HttpTypes.StoreRegion | undefined | null
if (countryCode) {
region = await getRegion(countryCode)
} else {
region = await retrieveRegion(regionId!)
}
if (!region) {
return {
response: { products: [], count: 0 },
nextPage: null,
}
}
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("products")),
}
return sdk.client
.fetch<{ products: HttpTypes.StoreProduct[]; count: number }>(
`/store/products`,
{
method: "GET",
query: {
limit,
offset,
region_id: region?.id,
fields:
"*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags",
...queryParams,
},
headers,
next,
cache: "force-cache",
}
)
.then(({ products, count }) => {
const nextPage = count > offset + limit ? pageParam + 1 : null
return {
response: {
products,
count,
},
nextPage: nextPage,
queryParams,
}
})
}
/**
* This will fetch 100 products to the Next.js cache and sort them based on the sortBy parameter.
* It will then return the paginated products based on the page and limit parameters.
*/
export const listProductsWithSort = async ({
page = 0,
queryParams,
sortBy = "created_at",
countryCode,
}: {
page?: number
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
sortBy?: SortOptions
countryCode: string
}): Promise<{
response: { products: HttpTypes.StoreProduct[]; count: number }
nextPage: number | null
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
}> => {
const limit = queryParams?.limit || 12
const {
response: { products, count },
} = await listProducts({
pageParam: 0,
queryParams: {
...queryParams,
limit: 100,
},
countryCode,
})
const sortedProducts = sortProducts(products, sortBy)
const pageParam = (page - 1) * limit
const nextPage = count > pageParam + limit ? pageParam + limit : null
const paginatedProducts = sortedProducts.slice(pageParam, pageParam + limit)
return {
response: {
products: paginatedProducts,
count,
},
nextPage,
queryParams,
}
}

View File

@@ -0,0 +1,66 @@
"use server"
import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { HttpTypes } from "@medusajs/types"
import { getCacheOptions } from "./cookies"
export const listRegions = async () => {
const next = {
...(await getCacheOptions("regions")),
}
return sdk.client
.fetch<{ regions: HttpTypes.StoreRegion[] }>(`/store/regions`, {
method: "GET",
next,
cache: "force-cache",
})
.then(({ regions }) => regions)
.catch(medusaError)
}
export const retrieveRegion = async (id: string) => {
const next = {
...(await getCacheOptions(["regions", id].join("-"))),
}
return sdk.client
.fetch<{ region: HttpTypes.StoreRegion }>(`/store/regions/${id}`, {
method: "GET",
next,
cache: "force-cache",
})
.then(({ region }) => region)
.catch(medusaError)
}
const regionMap = new Map<string, HttpTypes.StoreRegion>()
export const getRegion = async (countryCode: string) => {
try {
if (regionMap.has(countryCode)) {
return regionMap.get(countryCode)
}
const regions = await listRegions()
if (!regions) {
return null
}
regions.forEach((region) => {
region.countries?.forEach((c) => {
regionMap.set(c?.iso_2 ?? "", region)
})
})
const region = countryCode
? regionMap.get(countryCode)
: regionMap.get("us")
return region
} catch (e: any) {
return null
}
}

View File

@@ -0,0 +1,29 @@
import { RefObject, useEffect, useState } from "react"
export const useIntersection = (
element: RefObject<HTMLDivElement | null>,
rootMargin: string
) => {
const [isVisible, setState] = useState(false)
useEffect(() => {
if (!element.current) {
return
}
const el = element.current
const observer = new IntersectionObserver(
([entry]) => {
setState(entry.isIntersecting)
},
{ rootMargin }
)
observer.observe(el)
return () => observer.unobserve(el)
}, [element, rootMargin])
return isVisible
}

View File

@@ -0,0 +1,46 @@
import { useState } from "react"
export type StateType = [boolean, () => void, () => void, () => void] & {
state: boolean
open: () => void
close: () => void
toggle: () => void
}
/**
*
* @param initialState - boolean
* @returns An array like object with `state`, `open`, `close`, and `toggle` properties
* to allow both object and array destructuring
*
* ```
* const [showModal, openModal, closeModal, toggleModal] = useToggleState()
* // or
* const { state, open, close, toggle } = useToggleState()
* ```
*/
const useToggleState = (initialState = false) => {
const [state, setState] = useState<boolean>(initialState)
const close = () => {
setState(false)
}
const open = () => {
setState(true)
}
const toggle = () => {
setState((state) => !state)
}
const hookData = [state, open, close, toggle] as StateType
hookData.state = state
hookData.open = open
hookData.close = close
hookData.toggle = toggle
return hookData
}
export default useToggleState

View File

@@ -0,0 +1,28 @@
import { isEqual, pick } from "lodash"
export default function compareAddresses(address1: any, address2: any) {
return isEqual(
pick(address1, [
"first_name",
"last_name",
"address_1",
"company",
"postal_code",
"city",
"country_code",
"province",
"phone",
]),
pick(address2, [
"first_name",
"last_name",
"address_1",
"company",
"postal_code",
"city",
"country_code",
"province",
"phone",
])
)
}

View File

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

View File

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

View File

@@ -0,0 +1,79 @@
import { HttpTypes } from "@medusajs/types"
import { getPercentageDiff } from "./get-precentage-diff"
import { convertToLocale } from "./money"
export const getPricesForVariant = (variant: any) => {
if (!variant?.calculated_price?.calculated_amount) {
return null
}
return {
calculated_price_number: variant.calculated_price.calculated_amount,
calculated_price: convertToLocale({
amount: variant.calculated_price.calculated_amount,
currency_code: variant.calculated_price.currency_code,
}),
original_price_number: variant.calculated_price.original_amount,
original_price: convertToLocale({
amount: variant.calculated_price.original_amount,
currency_code: variant.calculated_price.currency_code,
}),
currency_code: variant.calculated_price.currency_code,
price_type: variant.calculated_price.calculated_price.price_list_type,
percentage_diff: getPercentageDiff(
variant.calculated_price.original_amount,
variant.calculated_price.calculated_amount
),
}
}
export function getProductPrice({
product,
variantId,
}: {
product: HttpTypes.StoreProduct
variantId?: string
}) {
if (!product || !product.id) {
throw new Error("No product provided")
}
const cheapestPrice = () => {
if (!product || !product.variants?.length) {
return null
}
const cheapestVariant: any = product.variants
.filter((v: any) => !!v.calculated_price)
.sort((a: any, b: any) => {
return (
a.calculated_price.calculated_amount -
b.calculated_price.calculated_amount
)
})[0]
return getPricesForVariant(cheapestVariant)
}
const variantPrice = () => {
if (!product || !variantId) {
return null
}
const variant: any = product.variants?.find(
(v) => v.id === variantId || v.sku === variantId
)
if (!variant) {
return null
}
return getPricesForVariant(variant)
}
return {
product,
cheapestPrice: cheapestPrice(),
variantPrice: variantPrice(),
}
}

View File

@@ -0,0 +1,11 @@
export const isObject = (input: any) => input instanceof Object
export const isArray = (input: any) => Array.isArray(input)
export const isEmpty = (input: any) => {
return (
input === null ||
input === undefined ||
(isObject(input) && Object.keys(input).length === 0) ||
(isArray(input) && (input as any[]).length === 0) ||
(typeof input === "string" && input.trim().length === 0)
)
}

View File

@@ -0,0 +1,22 @@
export default function medusaError(error: any): never {
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
const u = new URL(error.config.url, error.config.baseURL)
console.error("Resource:", u.toString())
console.error("Response data:", error.response.data)
console.error("Status code:", error.response.status)
console.error("Headers:", error.response.headers)
// Extracting the error message from the response data
const message = error.response.data.message || error.response.data
throw new Error(message.charAt(0).toUpperCase() + message.slice(1) + ".")
} else if (error.request) {
// The request was made but no response was received
throw new Error("No response received: " + error.request)
} else {
// Something happened in setting up the request that triggered an Error
throw new Error("Error setting up the request: " + error.message)
}
}

View File

@@ -0,0 +1,26 @@
import { isEmpty } from "./isEmpty"
type ConvertToLocaleParams = {
amount: number
currency_code: string
minimumFractionDigits?: number
maximumFractionDigits?: number
locale?: string
}
export const convertToLocale = ({
amount,
currency_code,
minimumFractionDigits,
maximumFractionDigits,
locale = "en-US",
}: ConvertToLocaleParams) => {
return currency_code && !isEmpty(currency_code)
? new Intl.NumberFormat(locale, {
style: "currency",
currency: currency_code,
minimumFractionDigits,
maximumFractionDigits,
}).format(amount)
: amount.toString()
}

View File

@@ -0,0 +1,5 @@
import { HttpTypes } from "@medusajs/types";
export const isSimpleProduct = (product: HttpTypes.StoreProduct): boolean => {
return product.options?.length === 1 && product.options[0].values?.length === 1;
}

View File

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

View File

@@ -0,0 +1,50 @@
import { HttpTypes } from "@medusajs/types"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
interface MinPricedProduct extends HttpTypes.StoreProduct {
_minPrice?: number
}
/**
* Helper function to sort products by price until the store API supports sorting by price
* @param products
* @param sortBy
* @returns products sorted by price
*/
export function sortProducts(
products: HttpTypes.StoreProduct[],
sortBy: SortOptions
): HttpTypes.StoreProduct[] {
let sortedProducts = products as MinPricedProduct[]
if (["price_asc", "price_desc"].includes(sortBy)) {
// Precompute the minimum price for each product
sortedProducts.forEach((product) => {
if (product.variants && product.variants.length > 0) {
product._minPrice = Math.min(
...product.variants.map(
(variant) => variant?.calculated_price?.calculated_amount || 0
)
)
} else {
product._minPrice = Infinity
}
})
// Sort products based on the precomputed minimum prices
sortedProducts.sort((a, b) => {
const diff = a._minPrice! - b._minPrice!
return sortBy === "price_asc" ? diff : -diff
})
}
if (sortBy === "created_at") {
sortedProducts.sort((a, b) => {
return (
new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()
)
})
}
return sortedProducts
}

View File

@@ -0,0 +1,159 @@
import { HttpTypes } from "@medusajs/types"
import { NextRequest, NextResponse } from "next/server"
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL
const PUBLISHABLE_API_KEY = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
const DEFAULT_REGION = process.env.NEXT_PUBLIC_DEFAULT_REGION || "us"
const regionMapCache = {
regionMap: new Map<string, HttpTypes.StoreRegion>(),
regionMapUpdated: Date.now(),
}
async function getRegionMap(cacheId: string) {
const { regionMap, regionMapUpdated } = regionMapCache
if (!BACKEND_URL) {
throw new Error(
"Middleware.ts: Error fetching regions. Did you set up regions in your Medusa Admin and define a MEDUSA_BACKEND_URL environment variable? Note that the variable is no longer named NEXT_PUBLIC_MEDUSA_BACKEND_URL."
)
}
if (
!regionMap.keys().next().value ||
regionMapUpdated < Date.now() - 3600 * 1000
) {
// Fetch regions from Medusa. We can't use the JS client here because middleware is running on Edge and the client needs a Node environment.
const { regions } = await fetch(`${BACKEND_URL}/store/regions`, {
headers: {
"x-publishable-api-key": PUBLISHABLE_API_KEY!,
},
next: {
revalidate: 3600,
tags: [`regions-${cacheId}`],
},
cache: "force-cache",
}).then(async (response) => {
const json = await response.json()
if (!response.ok) {
throw new Error(json.message)
}
return json
})
if (!regions?.length) {
throw new Error(
"No regions found. Please set up regions in your Medusa Admin."
)
}
// Create a map of country codes to regions.
regions.forEach((region: HttpTypes.StoreRegion) => {
region.countries?.forEach((c) => {
regionMapCache.regionMap.set(c.iso_2 ?? "", region)
})
})
regionMapCache.regionMapUpdated = Date.now()
}
return regionMapCache.regionMap
}
/**
* Fetches regions from Medusa and sets the region cookie.
* @param request
* @param response
*/
async function getCountryCode(
request: NextRequest,
regionMap: Map<string, HttpTypes.StoreRegion | number>
) {
try {
let countryCode
const vercelCountryCode = request.headers
.get("x-vercel-ip-country")
?.toLowerCase()
const urlCountryCode = request.nextUrl.pathname.split("/")[1]?.toLowerCase()
if (urlCountryCode && regionMap.has(urlCountryCode)) {
countryCode = urlCountryCode
} else if (vercelCountryCode && regionMap.has(vercelCountryCode)) {
countryCode = vercelCountryCode
} else if (regionMap.has(DEFAULT_REGION)) {
countryCode = DEFAULT_REGION
} else if (regionMap.keys().next().value) {
countryCode = regionMap.keys().next().value
}
return countryCode
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.error(
"Middleware.ts: Error getting the country code. Did you set up regions in your Medusa Admin and define a MEDUSA_BACKEND_URL environment variable? Note that the variable is no longer named NEXT_PUBLIC_MEDUSA_BACKEND_URL."
)
}
}
}
/**
* Middleware to handle region selection and onboarding status.
*/
export async function middleware(request: NextRequest) {
let redirectUrl = request.nextUrl.href
let response = NextResponse.redirect(redirectUrl, 307)
let cacheIdCookie = request.cookies.get("_medusa_cache_id")
let cacheId = cacheIdCookie?.value || crypto.randomUUID()
const regionMap = await getRegionMap(cacheId)
const countryCode = regionMap && (await getCountryCode(request, regionMap))
const urlHasCountryCode =
countryCode && request.nextUrl.pathname.split("/")[1].includes(countryCode)
// if one of the country codes is in the url and the cache id is set, return next
if (urlHasCountryCode && cacheIdCookie) {
return NextResponse.next()
}
// if one of the country codes is in the url and the cache id is not set, set the cache id and redirect
if (urlHasCountryCode && !cacheIdCookie) {
response.cookies.set("_medusa_cache_id", cacheId, {
maxAge: 60 * 60 * 24,
})
return response
}
// check if the url is a static asset
if (request.nextUrl.pathname.includes(".")) {
return NextResponse.next()
}
const redirectPath =
request.nextUrl.pathname === "/" ? "" : request.nextUrl.pathname
const queryString = request.nextUrl.search ? request.nextUrl.search : ""
// If no country code is set, we redirect to the relevant region.
if (!urlHasCountryCode && countryCode) {
redirectUrl = `${request.nextUrl.origin}/${countryCode}${redirectPath}${queryString}`
response = NextResponse.redirect(`${redirectUrl}`, 307)
}
return response
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|images|assets|png|svg|jpg|jpeg|gif|webp).*)",
],
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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