init starter package
This commit is contained in:
commit
b7c67b5834
|
|
@ -0,0 +1,3 @@
|
||||||
|
module.exports = {
|
||||||
|
extends: ["next/core-web-vitals"]
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"arrowParens": "always",
|
||||||
|
"semi": false,
|
||||||
|
"endOfLine": "auto",
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
compressionLevel: mixed
|
||||||
|
|
||||||
|
enableGlobalCache: false
|
||||||
|
|
||||||
|
nodeLinker: node-modules
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022 Medusa
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -0,0 +1,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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
const excludedPaths = ["/checkout", "/account/*"]
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
siteUrl: process.env.NEXT_PUBLIC_VERCEL_URL,
|
||||||
|
generateRobotsTxt: true,
|
||||||
|
exclude: excludedPaths + ["/[sitemap]"],
|
||||||
|
robotsTxtOptions: {
|
||||||
|
policies: [
|
||||||
|
{
|
||||||
|
userAgent: "*",
|
||||||
|
allow: "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userAgent: "*",
|
||||||
|
disallow: excludedPaths,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
const checkEnvVariables = require("./check-env-variables")
|
||||||
|
|
||||||
|
checkEnvVariables()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Medusa Cloud-related environment variables
|
||||||
|
*/
|
||||||
|
const S3_HOSTNAME = process.env.MEDUSA_CLOUD_S3_HOSTNAME
|
||||||
|
const S3_PATHNAME = process.env.MEDUSA_CLOUD_S3_PATHNAME
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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",
|
||||||
|
},
|
||||||
|
...(S3_HOSTNAME && S3_PATHNAME
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: S3_HOSTNAME,
|
||||||
|
pathname: S3_PATHNAME,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { retrieveCart } from "@lib/data/cart"
|
||||||
|
import { retrieveCustomer } from "@lib/data/customer"
|
||||||
|
import PaymentWrapper from "@modules/checkout/components/payment-wrapper"
|
||||||
|
import CheckoutForm from "@modules/checkout/templates/checkout-form"
|
||||||
|
import CheckoutSummary from "@modules/checkout/templates/checkout-summary"
|
||||||
|
import { Metadata } from "next"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Checkout",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Checkout() {
|
||||||
|
const cart = await retrieveCart()
|
||||||
|
|
||||||
|
if (!cart) {
|
||||||
|
return notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = await retrieveCustomer()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 small:grid-cols-[1fr_416px] content-container gap-x-40 py-12">
|
||||||
|
<PaymentWrapper cart={cart}>
|
||||||
|
<CheckoutForm cart={cart} customer={customer} />
|
||||||
|
</PaymentWrapper>
|
||||||
|
<CheckoutSummary cart={cart} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
import ChevronDown from "@modules/common/icons/chevron-down"
|
||||||
|
import MedusaCTA from "@modules/layout/components/medusa-cta"
|
||||||
|
|
||||||
|
export default function CheckoutLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white relative small:min-h-screen">
|
||||||
|
<div className="h-16 bg-white border-b ">
|
||||||
|
<nav className="flex h-full items-center content-container justify-between">
|
||||||
|
<LocalizedClientLink
|
||||||
|
href="/cart"
|
||||||
|
className="text-small-semi text-ui-fg-base flex items-center gap-x-2 uppercase flex-1 basis-0"
|
||||||
|
data-testid="back-to-cart-link"
|
||||||
|
>
|
||||||
|
<ChevronDown className="rotate-90" size={16} />
|
||||||
|
<span className="mt-px hidden small:block txt-compact-plus text-ui-fg-subtle hover:text-ui-fg-base ">
|
||||||
|
Back to shopping cart
|
||||||
|
</span>
|
||||||
|
<span className="mt-px block small:hidden txt-compact-plus text-ui-fg-subtle hover:text-ui-fg-base">
|
||||||
|
Back
|
||||||
|
</span>
|
||||||
|
</LocalizedClientLink>
|
||||||
|
<LocalizedClientLink
|
||||||
|
href="/"
|
||||||
|
className="txt-compact-xlarge-plus text-ui-fg-subtle hover:text-ui-fg-base uppercase"
|
||||||
|
data-testid="store-link"
|
||||||
|
>
|
||||||
|
Medusa Store
|
||||||
|
</LocalizedClientLink>
|
||||||
|
<div className="flex-1 basis-0" />
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div className="relative" data-testid="checkout-container">{children}</div>
|
||||||
|
<div className="py-4 w-full flex items-center justify-center">
|
||||||
|
<MedusaCTA />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import InteractiveLink from "@modules/common/components/interactive-link"
|
||||||
|
import { Metadata } from "next"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "404",
|
||||||
|
description: "Something went wrong",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 items-center justify-center min-h-[calc(100vh-64px)]">
|
||||||
|
<h1 className="text-2xl-semi text-ui-fg-base">Page not found</h1>
|
||||||
|
<p className="text-small-regular text-ui-fg-base">
|
||||||
|
The page you tried to access does not exist.
|
||||||
|
</p>
|
||||||
|
<InteractiveLink href="/">Go to frontpage</InteractiveLink>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Metadata } from "next"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
import AddressBook from "@modules/account/components/address-book"
|
||||||
|
|
||||||
|
import { getRegion } from "@lib/data/regions"
|
||||||
|
import { retrieveCustomer } from "@lib/data/customer"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Addresses",
|
||||||
|
description: "View your addresses",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Addresses(props: {
|
||||||
|
params: Promise<{ countryCode: string }>
|
||||||
|
}) {
|
||||||
|
const params = await props.params
|
||||||
|
const { countryCode } = params
|
||||||
|
const customer = await retrieveCustomer()
|
||||||
|
const region = await getRegion(countryCode)
|
||||||
|
|
||||||
|
if (!customer || !region) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full" data-testid="addresses-page-wrapper">
|
||||||
|
<div className="mb-8 flex flex-col gap-y-4">
|
||||||
|
<h1 className="text-2xl-semi">Shipping Addresses</h1>
|
||||||
|
<p className="text-base-regular">
|
||||||
|
View and update your shipping addresses, you can add as many as you
|
||||||
|
like. Saving your addresses will make them available during checkout.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AddressBook customer={customer} region={region} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import Spinner from "@modules/common/icons/spinner"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center w-full h-full text-ui-fg-base">
|
||||||
|
<Spinner size={36} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { retrieveOrder } from "@lib/data/orders"
|
||||||
|
import OrderDetailsTemplate from "@modules/order/templates/order-details-template"
|
||||||
|
import { Metadata } from "next"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(props: Props): Promise<Metadata> {
|
||||||
|
const params = await props.params
|
||||||
|
const order = await retrieveOrder(params.id).catch(() => null)
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `Order #${order.display_id}`,
|
||||||
|
description: `View your order`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OrderDetailPage(props: Props) {
|
||||||
|
const params = await props.params
|
||||||
|
const order = await retrieveOrder(params.id).catch(() => null)
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return <OrderDetailsTemplate order={order} />
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { Metadata } from "next"
|
||||||
|
|
||||||
|
import OrderOverview from "@modules/account/components/order-overview"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { listOrders } from "@lib/data/orders"
|
||||||
|
import Divider from "@modules/common/components/divider"
|
||||||
|
import TransferRequestForm from "@modules/account/components/transfer-request-form"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Orders",
|
||||||
|
description: "Overview of your previous orders.",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Orders() {
|
||||||
|
const orders = await listOrders()
|
||||||
|
|
||||||
|
if (!orders) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full" data-testid="orders-page-wrapper">
|
||||||
|
<div className="mb-8 flex flex-col gap-y-4">
|
||||||
|
<h1 className="text-2xl-semi">Orders</h1>
|
||||||
|
<p className="text-base-regular">
|
||||||
|
View your previous orders and their status. You can also create
|
||||||
|
returns or exchanges for your orders if needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<OrderOverview orders={orders} />
|
||||||
|
<Divider className="my-16" />
|
||||||
|
<TransferRequestForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Metadata } from "next"
|
||||||
|
|
||||||
|
import Overview from "@modules/account/components/overview"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { retrieveCustomer } from "@lib/data/customer"
|
||||||
|
import { listOrders } from "@lib/data/orders"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Account",
|
||||||
|
description: "Overview of your account activity.",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OverviewTemplate() {
|
||||||
|
const customer = await retrieveCustomer().catch(() => null)
|
||||||
|
const orders = (await listOrders().catch(() => null)) || null
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Overview customer={customer} orders={orders} />
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { Metadata } from "next"
|
||||||
|
|
||||||
|
import ProfilePhone from "@modules/account//components/profile-phone"
|
||||||
|
import ProfileBillingAddress from "@modules/account/components/profile-billing-address"
|
||||||
|
import ProfileEmail from "@modules/account/components/profile-email"
|
||||||
|
import ProfileName from "@modules/account/components/profile-name"
|
||||||
|
import ProfilePassword from "@modules/account/components/profile-password"
|
||||||
|
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { listRegions } from "@lib/data/regions"
|
||||||
|
import { retrieveCustomer } from "@lib/data/customer"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Profile",
|
||||||
|
description: "View and edit your Medusa Store profile.",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Profile() {
|
||||||
|
const customer = await retrieveCustomer()
|
||||||
|
const regions = await listRegions()
|
||||||
|
|
||||||
|
if (!customer || !regions) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full" data-testid="profile-page-wrapper">
|
||||||
|
<div className="mb-8 flex flex-col gap-y-4">
|
||||||
|
<h1 className="text-2xl-semi">Profile</h1>
|
||||||
|
<p className="text-base-regular">
|
||||||
|
View and update your profile information, including your name, email,
|
||||||
|
and phone number. You can also update your billing address, or change
|
||||||
|
your password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-y-8 w-full">
|
||||||
|
<ProfileName customer={customer} />
|
||||||
|
<Divider />
|
||||||
|
<ProfileEmail customer={customer} />
|
||||||
|
<Divider />
|
||||||
|
<ProfilePhone customer={customer} />
|
||||||
|
<Divider />
|
||||||
|
{/* <ProfilePassword customer={customer} />
|
||||||
|
<Divider /> */}
|
||||||
|
<ProfileBillingAddress customer={customer} regions={regions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Divider = () => {
|
||||||
|
return <div className="w-full h-px bg-gray-200" />
|
||||||
|
}
|
||||||
|
;``
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Metadata } from "next"
|
||||||
|
|
||||||
|
import LoginTemplate from "@modules/account/templates/login-template"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Sign in",
|
||||||
|
description: "Sign in to your Medusa Store account.",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
return <LoginTemplate />
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { retrieveCustomer } from "@lib/data/customer"
|
||||||
|
import { Toaster } from "@medusajs/ui"
|
||||||
|
import AccountLayout from "@modules/account/templates/account-layout"
|
||||||
|
|
||||||
|
export default async function AccountPageLayout({
|
||||||
|
dashboard,
|
||||||
|
login,
|
||||||
|
}: {
|
||||||
|
dashboard?: React.ReactNode
|
||||||
|
login?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const customer = await retrieveCustomer().catch(() => null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccountLayout customer={customer}>
|
||||||
|
{customer ? dashboard : login}
|
||||||
|
<Toaster />
|
||||||
|
</AccountLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import Spinner from "@modules/common/icons/spinner"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center w-full h-full text-ui-fg-base">
|
||||||
|
<Spinner size={36} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import SkeletonCartPage from "@modules/skeletons/templates/skeleton-cart-page"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <SkeletonCartPage />
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Metadata } from "next"
|
||||||
|
|
||||||
|
import InteractiveLink from "@modules/common/components/interactive-link"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "404",
|
||||||
|
description: "Something went wrong",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-64px)]">
|
||||||
|
<h1 className="text-2xl-semi text-ui-fg-base">Page not found</h1>
|
||||||
|
<p className="text-small-regular text-ui-fg-base">
|
||||||
|
The cart you tried to access does not exist. Clear your cookies and try
|
||||||
|
again.
|
||||||
|
</p>
|
||||||
|
<InteractiveLink href="/">Go to frontpage</InteractiveLink>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { retrieveCart } from "@lib/data/cart"
|
||||||
|
import { retrieveCustomer } from "@lib/data/customer"
|
||||||
|
import CartTemplate from "@modules/cart/templates"
|
||||||
|
import { Metadata } from "next"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Cart",
|
||||||
|
description: "View your cart",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Cart() {
|
||||||
|
const cart = await retrieveCart().catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
return notFound()
|
||||||
|
})
|
||||||
|
|
||||||
|
const customer = await retrieveCustomer()
|
||||||
|
|
||||||
|
return <CartTemplate cart={cart} customer={customer} />
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { Metadata } from "next"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
import { getCategoryByHandle, listCategories } from "@lib/data/categories"
|
||||||
|
import { listRegions } from "@lib/data/regions"
|
||||||
|
import { StoreRegion } from "@medusajs/types"
|
||||||
|
import CategoryTemplate from "@modules/categories/templates"
|
||||||
|
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ category: string[]; countryCode: string }>
|
||||||
|
searchParams: Promise<{
|
||||||
|
sortBy?: SortOptions
|
||||||
|
page?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const product_categories = await listCategories()
|
||||||
|
|
||||||
|
if (!product_categories) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const countryCodes = await listRegions().then((regions: StoreRegion[]) =>
|
||||||
|
regions?.map((r) => r.countries?.map((c) => c.iso_2)).flat()
|
||||||
|
)
|
||||||
|
|
||||||
|
const categoryHandles = product_categories.map(
|
||||||
|
(category: any) => category.handle
|
||||||
|
)
|
||||||
|
|
||||||
|
const staticParams = countryCodes
|
||||||
|
?.map((countryCode: string | undefined) =>
|
||||||
|
categoryHandles.map((handle: any) => ({
|
||||||
|
countryCode,
|
||||||
|
category: [handle],
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.flat()
|
||||||
|
|
||||||
|
return staticParams
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(props: Props): Promise<Metadata> {
|
||||||
|
const params = await props.params
|
||||||
|
try {
|
||||||
|
const productCategory = await getCategoryByHandle(params.category)
|
||||||
|
|
||||||
|
const title = productCategory.name + " | Medusa Store"
|
||||||
|
|
||||||
|
const description = productCategory.description ?? `${title} category.`
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${title} | Medusa Store`,
|
||||||
|
description,
|
||||||
|
alternates: {
|
||||||
|
canonical: `${params.category.join("/")}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CategoryPage(props: Props) {
|
||||||
|
const searchParams = await props.searchParams
|
||||||
|
const params = await props.params
|
||||||
|
const { sortBy, page } = searchParams
|
||||||
|
|
||||||
|
const productCategory = await getCategoryByHandle(params.category)
|
||||||
|
|
||||||
|
if (!productCategory) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CategoryTemplate
|
||||||
|
category={productCategory}
|
||||||
|
sortBy={sortBy}
|
||||||
|
page={page}
|
||||||
|
countryCode={params.countryCode}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { Metadata } from "next"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
import { getCollectionByHandle, listCollections } from "@lib/data/collections"
|
||||||
|
import { listRegions } from "@lib/data/regions"
|
||||||
|
import { StoreCollection, StoreRegion } from "@medusajs/types"
|
||||||
|
import CollectionTemplate from "@modules/collections/templates"
|
||||||
|
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ handle: string; countryCode: string }>
|
||||||
|
searchParams: Promise<{
|
||||||
|
page?: string
|
||||||
|
sortBy?: SortOptions
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRODUCT_LIMIT = 12
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const { collections } = await listCollections({
|
||||||
|
fields: "*products",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!collections) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const countryCodes = await listRegions().then(
|
||||||
|
(regions: StoreRegion[]) =>
|
||||||
|
regions
|
||||||
|
?.map((r) => r.countries?.map((c) => c.iso_2))
|
||||||
|
.flat()
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const collectionHandles = collections.map(
|
||||||
|
(collection: StoreCollection) => collection.handle
|
||||||
|
)
|
||||||
|
|
||||||
|
const staticParams = countryCodes
|
||||||
|
?.map((countryCode: string) =>
|
||||||
|
collectionHandles.map((handle: string | undefined) => ({
|
||||||
|
countryCode,
|
||||||
|
handle,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.flat()
|
||||||
|
|
||||||
|
return staticParams
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(props: Props): Promise<Metadata> {
|
||||||
|
const params = await props.params
|
||||||
|
const collection = await getCollectionByHandle(params.handle)
|
||||||
|
|
||||||
|
if (!collection) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
title: `${collection.title} | Medusa Store`,
|
||||||
|
description: `${collection.title} collection`,
|
||||||
|
} as Metadata
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CollectionPage(props: Props) {
|
||||||
|
const searchParams = await props.searchParams
|
||||||
|
const params = await props.params
|
||||||
|
const { sortBy, page } = searchParams
|
||||||
|
|
||||||
|
const collection = await getCollectionByHandle(params.handle).then(
|
||||||
|
(collection: StoreCollection) => collection
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!collection) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollectionTemplate
|
||||||
|
collection={collection}
|
||||||
|
page={page}
|
||||||
|
sortBy={sortBy}
|
||||||
|
countryCode={params.countryCode}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { Metadata } from "next"
|
||||||
|
|
||||||
|
import { listCartOptions, retrieveCart } from "@lib/data/cart"
|
||||||
|
import { retrieveCustomer } from "@lib/data/customer"
|
||||||
|
import { getBaseURL } from "@lib/util/env"
|
||||||
|
import { StoreCartShippingOption } from "@medusajs/types"
|
||||||
|
import CartMismatchBanner from "@modules/layout/components/cart-mismatch-banner"
|
||||||
|
import Footer from "@modules/layout/templates/footer"
|
||||||
|
import Nav from "@modules/layout/templates/nav"
|
||||||
|
import FreeShippingPriceNudge from "@modules/shipping/components/free-shipping-price-nudge"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(getBaseURL()),
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PageLayout(props: { children: React.ReactNode }) {
|
||||||
|
const customer = await retrieveCustomer()
|
||||||
|
const cart = await retrieveCart()
|
||||||
|
let shippingOptions: StoreCartShippingOption[] = []
|
||||||
|
|
||||||
|
if (cart) {
|
||||||
|
const { shipping_options } = await listCartOptions()
|
||||||
|
|
||||||
|
shippingOptions = shipping_options
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Nav />
|
||||||
|
{customer && cart && (
|
||||||
|
<CartMismatchBanner customer={customer} cart={cart} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cart && (
|
||||||
|
<FreeShippingPriceNudge
|
||||||
|
variant="popup"
|
||||||
|
cart={cart}
|
||||||
|
shippingOptions={shippingOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{props.children}
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { Metadata } from "next"
|
||||||
|
|
||||||
|
import InteractiveLink from "@modules/common/components/interactive-link"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "404",
|
||||||
|
description: "Something went wrong",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 items-center justify-center min-h-[calc(100vh-64px)]">
|
||||||
|
<h1 className="text-2xl-semi text-ui-fg-base">Page not found</h1>
|
||||||
|
<p className="text-small-regular text-ui-fg-base">
|
||||||
|
The page you tried to access does not exist.
|
||||||
|
</p>
|
||||||
|
<InteractiveLink href="/">Go to frontpage</InteractiveLink>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import SkeletonOrderConfirmed from "@modules/skeletons/templates/skeleton-order-confirmed"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <SkeletonOrderConfirmed />
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { retrieveOrder } from "@lib/data/orders"
|
||||||
|
import OrderCompletedTemplate from "@modules/order/templates/order-completed-template"
|
||||||
|
import { Metadata } from "next"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Order Confirmed",
|
||||||
|
description: "You purchase was successful",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OrderConfirmedPage(props: Props) {
|
||||||
|
const params = await props.params
|
||||||
|
const order = await retrieveOrder(params.id).catch(() => null)
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return <OrderCompletedTemplate order={order} />
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { acceptTransferRequest } from "@lib/data/orders"
|
||||||
|
import { Heading, Text } from "@medusajs/ui"
|
||||||
|
import TransferImage from "@modules/order/components/transfer-image"
|
||||||
|
|
||||||
|
export default async function TransferPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { id: string; token: string }
|
||||||
|
}) {
|
||||||
|
const { id, token } = params
|
||||||
|
|
||||||
|
const { success, error } = await acceptTransferRequest(id, token)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-4 items-start w-2/5 mx-auto mt-10 mb-20">
|
||||||
|
<TransferImage />
|
||||||
|
<div className="flex flex-col gap-y-6">
|
||||||
|
{success && (
|
||||||
|
<>
|
||||||
|
<Heading level="h1" className="text-xl text-zinc-900">
|
||||||
|
Order transfered!
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-zinc-600">
|
||||||
|
Order {id} has been successfully transfered to the new owner.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!success && (
|
||||||
|
<>
|
||||||
|
<Text className="text-zinc-600">
|
||||||
|
There was an error accepting the transfer. Please try again.
|
||||||
|
</Text>
|
||||||
|
{error && (
|
||||||
|
<Text className="text-red-500">Error message: {error}</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { declineTransferRequest } from "@lib/data/orders"
|
||||||
|
import { Heading, Text } from "@medusajs/ui"
|
||||||
|
import TransferImage from "@modules/order/components/transfer-image"
|
||||||
|
|
||||||
|
export default async function TransferPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { id: string; token: string }
|
||||||
|
}) {
|
||||||
|
const { id, token } = params
|
||||||
|
|
||||||
|
const { success, error } = await declineTransferRequest(id, token)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-4 items-start w-2/5 mx-auto mt-10 mb-20">
|
||||||
|
<TransferImage />
|
||||||
|
<div className="flex flex-col gap-y-6">
|
||||||
|
{success && (
|
||||||
|
<>
|
||||||
|
<Heading level="h1" className="text-xl text-zinc-900">
|
||||||
|
Order transfer declined!
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-zinc-600">
|
||||||
|
Transfer of order {id} has been successfully declined.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!success && (
|
||||||
|
<>
|
||||||
|
<Text className="text-zinc-600">
|
||||||
|
There was an error declining the transfer. Please try again.
|
||||||
|
</Text>
|
||||||
|
{error && (
|
||||||
|
<Text className="text-red-500">Error message: {error}</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Heading, Text } from "@medusajs/ui"
|
||||||
|
import TransferActions from "@modules/order/components/transfer-actions"
|
||||||
|
import TransferImage from "@modules/order/components/transfer-image"
|
||||||
|
|
||||||
|
export default async function TransferPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { id: string; token: string }
|
||||||
|
}) {
|
||||||
|
const { id, token } = params
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-4 items-start w-2/5 mx-auto mt-10 mb-20">
|
||||||
|
<TransferImage />
|
||||||
|
<div className="flex flex-col gap-y-6">
|
||||||
|
<Heading level="h1" className="text-xl text-zinc-900">
|
||||||
|
Transfer request for order {id}
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-zinc-600">
|
||||||
|
You'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { Metadata } from "next"
|
||||||
|
|
||||||
|
import FeaturedProducts from "@modules/home/components/featured-products"
|
||||||
|
import Hero from "@modules/home/components/hero"
|
||||||
|
import { listCollections } from "@lib/data/collections"
|
||||||
|
import { getRegion } from "@lib/data/regions"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Medusa Next.js Starter Template",
|
||||||
|
description:
|
||||||
|
"A performant frontend ecommerce starter template with Next.js 15 and Medusa.",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Home(props: {
|
||||||
|
params: Promise<{ countryCode: string }>
|
||||||
|
}) {
|
||||||
|
const params = await props.params
|
||||||
|
|
||||||
|
const { countryCode } = params
|
||||||
|
|
||||||
|
const region = await getRegion(countryCode)
|
||||||
|
|
||||||
|
const { collections } = await listCollections({
|
||||||
|
fields: "id, handle, title",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!collections || !region) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Hero />
|
||||||
|
<div className="py-12">
|
||||||
|
<ul className="flex flex-col gap-x-6">
|
||||||
|
<FeaturedProducts collections={collections} region={region} />
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { Metadata } from "next"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { listProducts } from "@lib/data/products"
|
||||||
|
import { getRegion, listRegions } from "@lib/data/regions"
|
||||||
|
import ProductTemplate from "@modules/products/templates"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ countryCode: string; handle: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
try {
|
||||||
|
const countryCodes = await listRegions().then((regions) =>
|
||||||
|
regions?.map((r) => r.countries?.map((c) => c.iso_2)).flat()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!countryCodes) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const promises = countryCodes.map(async (country) => {
|
||||||
|
const { response } = await listProducts({
|
||||||
|
countryCode: country,
|
||||||
|
queryParams: { limit: 100, fields: "handle" },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
country,
|
||||||
|
products: response.products,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const countryProducts = await Promise.all(promises)
|
||||||
|
|
||||||
|
return countryProducts
|
||||||
|
.flatMap((countryData) =>
|
||||||
|
countryData.products.map((product) => ({
|
||||||
|
countryCode: countryData.country,
|
||||||
|
handle: product.handle,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.filter((param) => param.handle)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to generate static paths for product pages: ${
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
}.`
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(props: Props): Promise<Metadata> {
|
||||||
|
const params = await props.params
|
||||||
|
const { handle } = params
|
||||||
|
const region = await getRegion(params.countryCode)
|
||||||
|
|
||||||
|
if (!region) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await listProducts({
|
||||||
|
countryCode: params.countryCode,
|
||||||
|
queryParams: { handle },
|
||||||
|
}).then(({ response }) => response.products[0])
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${product.title} | Medusa Store`,
|
||||||
|
description: `${product.title}`,
|
||||||
|
openGraph: {
|
||||||
|
title: `${product.title} | Medusa Store`,
|
||||||
|
description: `${product.title}`,
|
||||||
|
images: product.thumbnail ? [product.thumbnail] : [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductPage(props: Props) {
|
||||||
|
const params = await props.params
|
||||||
|
const region = await getRegion(params.countryCode)
|
||||||
|
|
||||||
|
if (!region) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricedProduct = await listProducts({
|
||||||
|
countryCode: params.countryCode,
|
||||||
|
queryParams: { handle: params.handle },
|
||||||
|
}).then(({ response }) => response.products[0])
|
||||||
|
|
||||||
|
if (!pricedProduct) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProductTemplate
|
||||||
|
product={pricedProduct}
|
||||||
|
region={region}
|
||||||
|
countryCode={params.countryCode}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { Metadata } from "next"
|
||||||
|
|
||||||
|
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
|
||||||
|
import StoreTemplate from "@modules/store/templates"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Store",
|
||||||
|
description: "Explore all of our products.",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
searchParams: Promise<{
|
||||||
|
sortBy?: SortOptions
|
||||||
|
page?: string
|
||||||
|
}>
|
||||||
|
params: Promise<{
|
||||||
|
countryCode: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function StorePage(props: Params) {
|
||||||
|
const params = await props.params;
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
const { sortBy, page } = searchParams
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StoreTemplate
|
||||||
|
sortBy={sortBy}
|
||||||
|
page={page}
|
||||||
|
countryCode={params.countryCode}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 |
|
|
@ -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,
|
||||||
|
})
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { createContext, useContext } from "react"
|
||||||
|
|
||||||
|
interface ModalContext {
|
||||||
|
close: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalContext = createContext<ModalContext | null>(null)
|
||||||
|
|
||||||
|
interface ModalProviderProps {
|
||||||
|
children?: React.ReactNode
|
||||||
|
close: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModalProvider = ({ children, close }: ModalProviderProps) => {
|
||||||
|
return (
|
||||||
|
<ModalContext.Provider
|
||||||
|
value={{
|
||||||
|
close,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ModalContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useModal = () => {
|
||||||
|
const context = useContext(ModalContext)
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error("useModal must be used within a ModalProvider")
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,470 @@
|
||||||
|
"use server"
|
||||||
|
|
||||||
|
import { sdk } from "@lib/config"
|
||||||
|
import medusaError from "@lib/util/medusa-error"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { revalidateTag } from "next/cache"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import {
|
||||||
|
getAuthHeaders,
|
||||||
|
getCacheOptions,
|
||||||
|
getCacheTag,
|
||||||
|
getCartId,
|
||||||
|
removeCartId,
|
||||||
|
setCartId,
|
||||||
|
} from "./cookies"
|
||||||
|
import { getRegion } from "./regions"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a cart by its ID. If no ID is provided, it will use the cart ID from the cookies.
|
||||||
|
* @param cartId - optional - The ID of the cart to retrieve.
|
||||||
|
* @returns The cart object if found, or null if not found.
|
||||||
|
*/
|
||||||
|
export async function retrieveCart(cartId?: string, fields?: string) {
|
||||||
|
const id = cartId || (await getCartId())
|
||||||
|
fields ??= "*items, *region, *items.product, *items.variant, *items.thumbnail, *items.metadata, +items.total, *promotions, +shipping_methods.name"
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...(await getAuthHeaders()),
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
...(await getCacheOptions("carts")),
|
||||||
|
}
|
||||||
|
|
||||||
|
return await sdk.client
|
||||||
|
.fetch<HttpTypes.StoreCartResponse>(`/store/carts/${id}`, {
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
fields
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
next,
|
||||||
|
cache: "force-cache",
|
||||||
|
})
|
||||||
|
.then(({ cart }: { cart: HttpTypes.StoreCart }) => cart)
|
||||||
|
.catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrSetCart(countryCode: string) {
|
||||||
|
const region = await getRegion(countryCode)
|
||||||
|
|
||||||
|
if (!region) {
|
||||||
|
throw new Error(`Region not found for country code: ${countryCode}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let cart = await retrieveCart(undefined, 'id,region_id')
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...(await getAuthHeaders()),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cart) {
|
||||||
|
const cartResp = await sdk.store.cart.create(
|
||||||
|
{ region_id: region.id },
|
||||||
|
{},
|
||||||
|
headers
|
||||||
|
)
|
||||||
|
cart = cartResp.cart
|
||||||
|
|
||||||
|
await setCartId(cart.id)
|
||||||
|
|
||||||
|
const cartCacheTag = await getCacheTag("carts")
|
||||||
|
revalidateTag(cartCacheTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cart && cart?.region_id !== region.id) {
|
||||||
|
await sdk.store.cart.update(cart.id, { region_id: region.id }, {}, headers)
|
||||||
|
const cartCacheTag = await getCacheTag("carts")
|
||||||
|
revalidateTag(cartCacheTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cart
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCart(data: HttpTypes.StoreUpdateCart) {
|
||||||
|
const cartId = await getCartId()
|
||||||
|
|
||||||
|
if (!cartId) {
|
||||||
|
throw new Error("No existing cart found, please create one before updating")
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...(await getAuthHeaders()),
|
||||||
|
}
|
||||||
|
|
||||||
|
return sdk.store.cart
|
||||||
|
.update(cartId, data, {}, headers)
|
||||||
|
.then(async ({ cart }: { cart: HttpTypes.StoreCart }) => {
|
||||||
|
const cartCacheTag = await getCacheTag("carts")
|
||||||
|
revalidateTag(cartCacheTag)
|
||||||
|
|
||||||
|
const fulfillmentCacheTag = await getCacheTag("fulfillment")
|
||||||
|
revalidateTag(fulfillmentCacheTag)
|
||||||
|
|
||||||
|
return cart
|
||||||
|
})
|
||||||
|
.catch(medusaError)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addToCart({
|
||||||
|
variantId,
|
||||||
|
quantity,
|
||||||
|
countryCode,
|
||||||
|
}: {
|
||||||
|
variantId: string
|
||||||
|
quantity: number
|
||||||
|
countryCode: string
|
||||||
|
}) {
|
||||||
|
if (!variantId) {
|
||||||
|
throw new Error("Missing variant ID when adding to cart")
|
||||||
|
}
|
||||||
|
|
||||||
|
const cart = await getOrSetCart(countryCode)
|
||||||
|
|
||||||
|
if (!cart) {
|
||||||
|
throw new Error("Error retrieving or creating cart")
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...(await getAuthHeaders()),
|
||||||
|
}
|
||||||
|
|
||||||
|
await sdk.store.cart
|
||||||
|
.createLineItem(
|
||||||
|
cart.id,
|
||||||
|
{
|
||||||
|
variant_id: variantId,
|
||||||
|
quantity,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
headers
|
||||||
|
)
|
||||||
|
.then(async () => {
|
||||||
|
const cartCacheTag = await getCacheTag("carts")
|
||||||
|
revalidateTag(cartCacheTag)
|
||||||
|
|
||||||
|
const fulfillmentCacheTag = await getCacheTag("fulfillment")
|
||||||
|
revalidateTag(fulfillmentCacheTag)
|
||||||
|
})
|
||||||
|
.catch(medusaError)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLineItem({
|
||||||
|
lineId,
|
||||||
|
quantity,
|
||||||
|
}: {
|
||||||
|
lineId: string
|
||||||
|
quantity: number
|
||||||
|
}) {
|
||||||
|
if (!lineId) {
|
||||||
|
throw new Error("Missing lineItem ID when updating line item")
|
||||||
|
}
|
||||||
|
|
||||||
|
const cartId = await getCartId()
|
||||||
|
|
||||||
|
if (!cartId) {
|
||||||
|
throw new Error("Missing cart ID when updating line item")
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...(await getAuthHeaders()),
|
||||||
|
}
|
||||||
|
|
||||||
|
await sdk.store.cart
|
||||||
|
.updateLineItem(cartId, lineId, { quantity }, {}, headers)
|
||||||
|
.then(async () => {
|
||||||
|
const cartCacheTag = await getCacheTag("carts")
|
||||||
|
revalidateTag(cartCacheTag)
|
||||||
|
|
||||||
|
const fulfillmentCacheTag = await getCacheTag("fulfillment")
|
||||||
|
revalidateTag(fulfillmentCacheTag)
|
||||||
|
})
|
||||||
|
.catch(medusaError)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLineItem(lineId: string) {
|
||||||
|
if (!lineId) {
|
||||||
|
throw new Error("Missing lineItem ID when deleting line item")
|
||||||
|
}
|
||||||
|
|
||||||
|
const cartId = await getCartId()
|
||||||
|
|
||||||
|
if (!cartId) {
|
||||||
|
throw new Error("Missing cart ID when deleting line item")
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...(await getAuthHeaders()),
|
||||||
|
}
|
||||||
|
|
||||||
|
await sdk.store.cart
|
||||||
|
.deleteLineItem(cartId, lineId, headers)
|
||||||
|
.then(async () => {
|
||||||
|
const cartCacheTag = await getCacheTag("carts")
|
||||||
|
revalidateTag(cartCacheTag)
|
||||||
|
|
||||||
|
const fulfillmentCacheTag = await getCacheTag("fulfillment")
|
||||||
|
revalidateTag(fulfillmentCacheTag)
|
||||||
|
})
|
||||||
|
.catch(medusaError)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setShippingMethod({
|
||||||
|
cartId,
|
||||||
|
shippingMethodId,
|
||||||
|
}: {
|
||||||
|
cartId: string
|
||||||
|
shippingMethodId: string
|
||||||
|
}) {
|
||||||
|
const headers = {
|
||||||
|
...(await getAuthHeaders()),
|
||||||
|
}
|
||||||
|
|
||||||
|
return sdk.store.cart
|
||||||
|
.addShippingMethod(cartId, { option_id: shippingMethodId }, {}, headers)
|
||||||
|
.then(async () => {
|
||||||
|
const cartCacheTag = await getCacheTag("carts")
|
||||||
|
revalidateTag(cartCacheTag)
|
||||||
|
})
|
||||||
|
.catch(medusaError)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initiatePaymentSession(
|
||||||
|
cart: HttpTypes.StoreCart,
|
||||||
|
data: HttpTypes.StoreInitializePaymentSession
|
||||||
|
) {
|
||||||
|
const headers = {
|
||||||
|
...(await getAuthHeaders()),
|
||||||
|
}
|
||||||
|
|
||||||
|
return sdk.store.payment
|
||||||
|
.initiatePaymentSession(cart, data, {}, headers)
|
||||||
|
.then(async (resp) => {
|
||||||
|
const cartCacheTag = await getCacheTag("carts")
|
||||||
|
revalidateTag(cartCacheTag)
|
||||||
|
return resp
|
||||||
|
})
|
||||||
|
.catch(medusaError)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyPromotions(codes: string[]) {
|
||||||
|
const cartId = await getCartId()
|
||||||
|
|
||||||
|
if (!cartId) {
|
||||||
|
throw new Error("No existing cart found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...(await getAuthHeaders()),
|
||||||
|
}
|
||||||
|
|
||||||
|
return sdk.store.cart
|
||||||
|
.update(cartId, { promo_codes: codes }, {}, headers)
|
||||||
|
.then(async () => {
|
||||||
|
const cartCacheTag = await getCacheTag("carts")
|
||||||
|
revalidateTag(cartCacheTag)
|
||||||
|
|
||||||
|
const fulfillmentCacheTag = await getCacheTag("fulfillment")
|
||||||
|
revalidateTag(fulfillmentCacheTag)
|
||||||
|
})
|
||||||
|
.catch(medusaError)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyGiftCard(code: string) {
|
||||||
|
// const cartId = getCartId()
|
||||||
|
// if (!cartId) return "No cartId cookie found"
|
||||||
|
// try {
|
||||||
|
// await updateCart(cartId, { gift_cards: [{ code }] }).then(() => {
|
||||||
|
// revalidateTag("cart")
|
||||||
|
// })
|
||||||
|
// } catch (error: any) {
|
||||||
|
// throw error
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeDiscount(code: string) {
|
||||||
|
// const cartId = getCartId()
|
||||||
|
// if (!cartId) return "No cartId cookie found"
|
||||||
|
// try {
|
||||||
|
// await deleteDiscount(cartId, code)
|
||||||
|
// revalidateTag("cart")
|
||||||
|
// } catch (error: any) {
|
||||||
|
// throw error
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeGiftCard(
|
||||||
|
codeToRemove: string,
|
||||||
|
giftCards: any[]
|
||||||
|
// giftCards: GiftCard[]
|
||||||
|
) {
|
||||||
|
// const cartId = getCartId()
|
||||||
|
// if (!cartId) return "No cartId cookie found"
|
||||||
|
// try {
|
||||||
|
// await updateCart(cartId, {
|
||||||
|
// gift_cards: [...giftCards]
|
||||||
|
// .filter((gc) => gc.code !== codeToRemove)
|
||||||
|
// .map((gc) => ({ code: gc.code })),
|
||||||
|
// }).then(() => {
|
||||||
|
// revalidateTag("cart")
|
||||||
|
// })
|
||||||
|
// } catch (error: any) {
|
||||||
|
// throw error
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitPromotionForm(
|
||||||
|
currentState: unknown,
|
||||||
|
formData: FormData
|
||||||
|
) {
|
||||||
|
const code = formData.get("code") as string
|
||||||
|
try {
|
||||||
|
await applyPromotions([code])
|
||||||
|
} catch (e: any) {
|
||||||
|
return e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Pass a POJO instead of a form entity here
|
||||||
|
export async function setAddresses(currentState: unknown, formData: FormData) {
|
||||||
|
try {
|
||||||
|
if (!formData) {
|
||||||
|
throw new Error("No form data found when setting addresses")
|
||||||
|
}
|
||||||
|
const cartId = getCartId()
|
||||||
|
if (!cartId) {
|
||||||
|
throw new Error("No existing cart found when setting addresses")
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
shipping_address: {
|
||||||
|
first_name: formData.get("shipping_address.first_name"),
|
||||||
|
last_name: formData.get("shipping_address.last_name"),
|
||||||
|
address_1: formData.get("shipping_address.address_1"),
|
||||||
|
address_2: "",
|
||||||
|
company: formData.get("shipping_address.company"),
|
||||||
|
postal_code: formData.get("shipping_address.postal_code"),
|
||||||
|
city: formData.get("shipping_address.city"),
|
||||||
|
country_code: formData.get("shipping_address.country_code"),
|
||||||
|
province: formData.get("shipping_address.province"),
|
||||||
|
phone: formData.get("shipping_address.phone"),
|
||||||
|
},
|
||||||
|
email: formData.get("email"),
|
||||||
|
} as any
|
||||||
|
|
||||||
|
const sameAsBilling = formData.get("same_as_billing")
|
||||||
|
if (sameAsBilling === "on") data.billing_address = data.shipping_address
|
||||||
|
|
||||||
|
if (sameAsBilling !== "on")
|
||||||
|
data.billing_address = {
|
||||||
|
first_name: formData.get("billing_address.first_name"),
|
||||||
|
last_name: formData.get("billing_address.last_name"),
|
||||||
|
address_1: formData.get("billing_address.address_1"),
|
||||||
|
address_2: "",
|
||||||
|
company: formData.get("billing_address.company"),
|
||||||
|
postal_code: formData.get("billing_address.postal_code"),
|
||||||
|
city: formData.get("billing_address.city"),
|
||||||
|
country_code: formData.get("billing_address.country_code"),
|
||||||
|
province: formData.get("billing_address.province"),
|
||||||
|
phone: formData.get("billing_address.phone"),
|
||||||
|
}
|
||||||
|
await updateCart(data)
|
||||||
|
} catch (e: any) {
|
||||||
|
return e.message
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(
|
||||||
|
`/${formData.get("shipping_address.country_code")}/checkout?step=delivery`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places an order for a cart. If no cart ID is provided, it will use the cart ID from the cookies.
|
||||||
|
* @param cartId - optional - The ID of the cart to place an order for.
|
||||||
|
* @returns The cart object if the order was successful, or null if not.
|
||||||
|
*/
|
||||||
|
export async function placeOrder(cartId?: string) {
|
||||||
|
const id = cartId || (await getCartId())
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw new Error("No existing cart found when placing an order")
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...(await getAuthHeaders()),
|
||||||
|
}
|
||||||
|
|
||||||
|
const cartRes = await sdk.store.cart
|
||||||
|
.complete(id, {}, headers)
|
||||||
|
.then(async (cartRes) => {
|
||||||
|
const cartCacheTag = await getCacheTag("carts")
|
||||||
|
revalidateTag(cartCacheTag)
|
||||||
|
return cartRes
|
||||||
|
})
|
||||||
|
.catch(medusaError)
|
||||||
|
|
||||||
|
if (cartRes?.type === "order") {
|
||||||
|
const countryCode =
|
||||||
|
cartRes.order.shipping_address?.country_code?.toLowerCase()
|
||||||
|
|
||||||
|
const orderCacheTag = await getCacheTag("orders")
|
||||||
|
revalidateTag(orderCacheTag)
|
||||||
|
|
||||||
|
removeCartId()
|
||||||
|
redirect(`/${countryCode}/order/${cartRes?.order.id}/confirmed`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cartRes.cart
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the countrycode param and revalidates the regions cache
|
||||||
|
* @param regionId
|
||||||
|
* @param countryCode
|
||||||
|
*/
|
||||||
|
export async function updateRegion(countryCode: string, currentPath: string) {
|
||||||
|
const cartId = await getCartId()
|
||||||
|
const region = await getRegion(countryCode)
|
||||||
|
|
||||||
|
if (!region) {
|
||||||
|
throw new Error(`Region not found for country code: ${countryCode}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cartId) {
|
||||||
|
await updateCart({ region_id: region.id })
|
||||||
|
const cartCacheTag = await getCacheTag("carts")
|
||||||
|
revalidateTag(cartCacheTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
const regionCacheTag = await getCacheTag("regions")
|
||||||
|
revalidateTag(regionCacheTag)
|
||||||
|
|
||||||
|
const productsCacheTag = await getCacheTag("products")
|
||||||
|
revalidateTag(productsCacheTag)
|
||||||
|
|
||||||
|
redirect(`/${countryCode}${currentPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCartOptions() {
|
||||||
|
const cartId = await getCartId()
|
||||||
|
const headers = {
|
||||||
|
...(await getAuthHeaders()),
|
||||||
|
}
|
||||||
|
const next = {
|
||||||
|
...(await getCacheOptions("shippingOptions")),
|
||||||
|
}
|
||||||
|
|
||||||
|
return await sdk.client.fetch<{
|
||||||
|
shipping_options: HttpTypes.StoreCartShippingOption[]
|
||||||
|
}>("/store/shipping-options", {
|
||||||
|
query: { cart_id: cartId },
|
||||||
|
next,
|
||||||
|
headers,
|
||||||
|
cache: "force-cache",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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])
|
||||||
|
}
|
||||||
|
|
@ -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])
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
"use server"
|
||||||
|
|
||||||
|
import { sdk } from "@lib/config"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { getAuthHeaders, getCacheOptions } from "./cookies"
|
||||||
|
|
||||||
|
export const listCartShippingMethods = async (cartId: string) => {
|
||||||
|
const headers = {
|
||||||
|
...(await getAuthHeaders()),
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
...(await getCacheOptions("fulfillment")),
|
||||||
|
}
|
||||||
|
|
||||||
|
return sdk.client
|
||||||
|
.fetch<HttpTypes.StoreShippingOptionListResponse>(
|
||||||
|
`/store/shipping-options`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
cart_id: cartId,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
next,
|
||||||
|
cache: "force-cache",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(({ shipping_options }) => shipping_options)
|
||||||
|
.catch(() => {
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculatePriceForShippingOption = async (
|
||||||
|
optionId: string,
|
||||||
|
cartId: string,
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
) => {
|
||||||
|
const headers = {
|
||||||
|
...(await getAuthHeaders()),
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
...(await getCacheOptions("fulfillment")),
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = { cart_id: cartId, data }
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
body.data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
return sdk.client
|
||||||
|
.fetch<{ shipping_option: HttpTypes.StoreCartShippingOption }>(
|
||||||
|
`/store/shipping-options/${optionId}/calculate`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
next,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(({ shipping_option }) => shipping_option)
|
||||||
|
.catch((e) => {
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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}`)
|
||||||
|
}
|
||||||
|
|
@ -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 }))
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
"use server"
|
||||||
|
|
||||||
|
import { sdk } from "@lib/config"
|
||||||
|
import { sortProducts } from "@lib/util/sort-products"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
|
||||||
|
import { getAuthHeaders, getCacheOptions } from "./cookies"
|
||||||
|
import { getRegion, retrieveRegion } from "./regions"
|
||||||
|
|
||||||
|
export const listProducts = async ({
|
||||||
|
pageParam = 1,
|
||||||
|
queryParams,
|
||||||
|
countryCode,
|
||||||
|
regionId,
|
||||||
|
}: {
|
||||||
|
pageParam?: number
|
||||||
|
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductListParams
|
||||||
|
countryCode?: string
|
||||||
|
regionId?: string
|
||||||
|
}): Promise<{
|
||||||
|
response: { products: HttpTypes.StoreProduct[]; count: number }
|
||||||
|
nextPage: number | null
|
||||||
|
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductListParams
|
||||||
|
}> => {
|
||||||
|
if (!countryCode && !regionId) {
|
||||||
|
throw new Error("Country code or region ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = queryParams?.limit || 12
|
||||||
|
const _pageParam = Math.max(pageParam, 1)
|
||||||
|
const offset = _pageParam === 1 ? 0 : (_pageParam - 1) * limit
|
||||||
|
|
||||||
|
let region: HttpTypes.StoreRegion | undefined | null
|
||||||
|
|
||||||
|
if (countryCode) {
|
||||||
|
region = await getRegion(countryCode)
|
||||||
|
} else {
|
||||||
|
region = await retrieveRegion(regionId!)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!region) {
|
||||||
|
return {
|
||||||
|
response: { products: [], count: 0 },
|
||||||
|
nextPage: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...(await getAuthHeaders()),
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
...(await getCacheOptions("products")),
|
||||||
|
}
|
||||||
|
|
||||||
|
return sdk.client
|
||||||
|
.fetch<{ products: HttpTypes.StoreProduct[]; count: number }>(
|
||||||
|
`/store/products`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
region_id: region?.id,
|
||||||
|
fields:
|
||||||
|
"*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags",
|
||||||
|
...queryParams,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
next,
|
||||||
|
cache: "force-cache",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(({ products, count }) => {
|
||||||
|
const nextPage = count > offset + limit ? pageParam + 1 : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: {
|
||||||
|
products,
|
||||||
|
count,
|
||||||
|
},
|
||||||
|
nextPage: nextPage,
|
||||||
|
queryParams,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This will fetch 100 products to the Next.js cache and sort them based on the sortBy parameter.
|
||||||
|
* It will then return the paginated products based on the page and limit parameters.
|
||||||
|
*/
|
||||||
|
export const listProductsWithSort = async ({
|
||||||
|
page = 0,
|
||||||
|
queryParams,
|
||||||
|
sortBy = "created_at",
|
||||||
|
countryCode,
|
||||||
|
}: {
|
||||||
|
page?: number
|
||||||
|
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
|
||||||
|
sortBy?: SortOptions
|
||||||
|
countryCode: string
|
||||||
|
}): Promise<{
|
||||||
|
response: { products: HttpTypes.StoreProduct[]; count: number }
|
||||||
|
nextPage: number | null
|
||||||
|
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
|
||||||
|
}> => {
|
||||||
|
const limit = queryParams?.limit || 12
|
||||||
|
|
||||||
|
const {
|
||||||
|
response: { products, count },
|
||||||
|
} = await listProducts({
|
||||||
|
pageParam: 0,
|
||||||
|
queryParams: {
|
||||||
|
...queryParams,
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
countryCode,
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedProducts = sortProducts(products, sortBy)
|
||||||
|
|
||||||
|
const pageParam = (page - 1) * limit
|
||||||
|
|
||||||
|
const nextPage = count > pageParam + limit ? pageParam + limit : null
|
||||||
|
|
||||||
|
const paginatedProducts = sortedProducts.slice(pageParam, pageParam + limit)
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: {
|
||||||
|
products: paginatedProducts,
|
||||||
|
count,
|
||||||
|
},
|
||||||
|
nextPage,
|
||||||
|
queryParams,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
export type StateType = [boolean, () => void, () => void, () => void] & {
|
||||||
|
state: boolean
|
||||||
|
open: () => void
|
||||||
|
close: () => void
|
||||||
|
toggle: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param initialState - boolean
|
||||||
|
* @returns An array like object with `state`, `open`, `close`, and `toggle` properties
|
||||||
|
* to allow both object and array destructuring
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* const [showModal, openModal, closeModal, toggleModal] = useToggleState()
|
||||||
|
* // or
|
||||||
|
* const { state, open, close, toggle } = useToggleState()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
const useToggleState = (initialState = false) => {
|
||||||
|
const [state, setState] = useState<boolean>(initialState)
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setState(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
setState(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
setState((state) => !state)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hookData = [state, open, close, toggle] as StateType
|
||||||
|
hookData.state = state
|
||||||
|
hookData.open = open
|
||||||
|
hookData.close = close
|
||||||
|
hookData.toggle = toggle
|
||||||
|
return hookData
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useToggleState
|
||||||
|
|
@ -0,0 +1,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",
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const getBaseURL = () => {
|
||||||
|
return process.env.NEXT_PUBLIC_BASE_URL || "https://localhost:8000"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export const getPercentageDiff = (original: number, calculated: number) => {
|
||||||
|
const diff = original - calculated
|
||||||
|
const decrease = (diff / original) * 100
|
||||||
|
|
||||||
|
return decrease.toFixed()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
const repeat = (times: number) => {
|
||||||
|
return Array.from(Array(times).keys())
|
||||||
|
}
|
||||||
|
|
||||||
|
export default repeat
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL
|
||||||
|
const PUBLISHABLE_API_KEY = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
|
||||||
|
const DEFAULT_REGION = process.env.NEXT_PUBLIC_DEFAULT_REGION || "us"
|
||||||
|
|
||||||
|
const regionMapCache = {
|
||||||
|
regionMap: new Map<string, HttpTypes.StoreRegion>(),
|
||||||
|
regionMapUpdated: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRegionMap(cacheId: string) {
|
||||||
|
const { regionMap, regionMapUpdated } = regionMapCache
|
||||||
|
|
||||||
|
if (!BACKEND_URL) {
|
||||||
|
throw new Error(
|
||||||
|
"Middleware.ts: Error fetching regions. Did you set up regions in your Medusa Admin and define a MEDUSA_BACKEND_URL environment variable? Note that the variable is no longer named NEXT_PUBLIC_MEDUSA_BACKEND_URL."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!regionMap.keys().next().value ||
|
||||||
|
regionMapUpdated < Date.now() - 3600 * 1000
|
||||||
|
) {
|
||||||
|
// Fetch regions from Medusa. We can't use the JS client here because middleware is running on Edge and the client needs a Node environment.
|
||||||
|
const { regions } = await fetch(`${BACKEND_URL}/store/regions`, {
|
||||||
|
headers: {
|
||||||
|
"x-publishable-api-key": PUBLISHABLE_API_KEY!,
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: 3600,
|
||||||
|
tags: [`regions-${cacheId}`],
|
||||||
|
},
|
||||||
|
cache: "force-cache",
|
||||||
|
}).then(async (response) => {
|
||||||
|
const json = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(json.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return json
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!regions?.length) {
|
||||||
|
throw new Error(
|
||||||
|
"No regions found. Please set up regions in your Medusa Admin."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of country codes to regions.
|
||||||
|
regions.forEach((region: HttpTypes.StoreRegion) => {
|
||||||
|
region.countries?.forEach((c) => {
|
||||||
|
regionMapCache.regionMap.set(c.iso_2 ?? "", region)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
regionMapCache.regionMapUpdated = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
return regionMapCache.regionMap
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches regions from Medusa and sets the region cookie.
|
||||||
|
* @param request
|
||||||
|
* @param response
|
||||||
|
*/
|
||||||
|
async function getCountryCode(
|
||||||
|
request: NextRequest,
|
||||||
|
regionMap: Map<string, HttpTypes.StoreRegion | number>
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
let countryCode
|
||||||
|
|
||||||
|
const vercelCountryCode = request.headers
|
||||||
|
.get("x-vercel-ip-country")
|
||||||
|
?.toLowerCase()
|
||||||
|
|
||||||
|
const urlCountryCode = request.nextUrl.pathname.split("/")[1]?.toLowerCase()
|
||||||
|
|
||||||
|
if (urlCountryCode && regionMap.has(urlCountryCode)) {
|
||||||
|
countryCode = urlCountryCode
|
||||||
|
} else if (vercelCountryCode && regionMap.has(vercelCountryCode)) {
|
||||||
|
countryCode = vercelCountryCode
|
||||||
|
} else if (regionMap.has(DEFAULT_REGION)) {
|
||||||
|
countryCode = DEFAULT_REGION
|
||||||
|
} else if (regionMap.keys().next().value) {
|
||||||
|
countryCode = regionMap.keys().next().value
|
||||||
|
}
|
||||||
|
|
||||||
|
return countryCode
|
||||||
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.error(
|
||||||
|
"Middleware.ts: Error getting the country code. Did you set up regions in your Medusa Admin and define a MEDUSA_BACKEND_URL environment variable? Note that the variable is no longer named NEXT_PUBLIC_MEDUSA_BACKEND_URL."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to handle region selection and onboarding status.
|
||||||
|
*/
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
let redirectUrl = request.nextUrl.href
|
||||||
|
|
||||||
|
let response = NextResponse.redirect(redirectUrl, 307)
|
||||||
|
|
||||||
|
let cacheIdCookie = request.cookies.get("_medusa_cache_id")
|
||||||
|
|
||||||
|
let cacheId = cacheIdCookie?.value || crypto.randomUUID()
|
||||||
|
|
||||||
|
const regionMap = await getRegionMap(cacheId)
|
||||||
|
|
||||||
|
const countryCode = regionMap && (await getCountryCode(request, regionMap))
|
||||||
|
|
||||||
|
const urlHasCountryCode =
|
||||||
|
countryCode && request.nextUrl.pathname.split("/")[1].includes(countryCode)
|
||||||
|
|
||||||
|
// if one of the country codes is in the url and the cache id is set, return next
|
||||||
|
if (urlHasCountryCode && cacheIdCookie) {
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// if one of the country codes is in the url and the cache id is not set, set the cache id and redirect
|
||||||
|
if (urlHasCountryCode && !cacheIdCookie) {
|
||||||
|
response.cookies.set("_medusa_cache_id", cacheId, {
|
||||||
|
maxAge: 60 * 60 * 24,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the url is a static asset
|
||||||
|
if (request.nextUrl.pathname.includes(".")) {
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectPath =
|
||||||
|
request.nextUrl.pathname === "/" ? "" : request.nextUrl.pathname
|
||||||
|
|
||||||
|
const queryString = request.nextUrl.search ? request.nextUrl.search : ""
|
||||||
|
|
||||||
|
// If no country code is set, we redirect to the relevant region.
|
||||||
|
if (!urlHasCountryCode && countryCode) {
|
||||||
|
redirectUrl = `${request.nextUrl.origin}/${countryCode}${redirectPath}${queryString}`
|
||||||
|
response = NextResponse.redirect(`${redirectUrl}`, 307)
|
||||||
|
} else if (!urlHasCountryCode && !countryCode) {
|
||||||
|
// Handle case where no valid country code exists (empty regions)
|
||||||
|
return new NextResponse(
|
||||||
|
"No valid regions configured. Please set up regions with countries in your Medusa Admin.",
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/((?!api|_next/static|_next/image|favicon.ico|images|assets|png|svg|jpg|jpeg|gif|webp).*)",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { Disclosure } from "@headlessui/react"
|
||||||
|
import { Badge, Button, clx } from "@medusajs/ui"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
import useToggleState from "@lib/hooks/use-toggle-state"
|
||||||
|
import { useFormStatus } from "react-dom"
|
||||||
|
|
||||||
|
type AccountInfoProps = {
|
||||||
|
label: string
|
||||||
|
currentInfo: string | React.ReactNode
|
||||||
|
isSuccess?: boolean
|
||||||
|
isError?: boolean
|
||||||
|
errorMessage?: string
|
||||||
|
clearState: () => void
|
||||||
|
children?: React.ReactNode
|
||||||
|
'data-testid'?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccountInfo = ({
|
||||||
|
label,
|
||||||
|
currentInfo,
|
||||||
|
isSuccess,
|
||||||
|
isError,
|
||||||
|
clearState,
|
||||||
|
errorMessage = "An error occurred, please try again",
|
||||||
|
children,
|
||||||
|
'data-testid': dataTestid
|
||||||
|
}: AccountInfoProps) => {
|
||||||
|
const { state, close, toggle } = useToggleState()
|
||||||
|
|
||||||
|
const { pending } = useFormStatus()
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
clearState()
|
||||||
|
setTimeout(() => toggle(), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSuccess) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}, [isSuccess, close])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-small-regular" data-testid={dataTestid}>
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="uppercase text-ui-fg-base">{label}</span>
|
||||||
|
<div className="flex items-center flex-1 basis-0 justify-end gap-x-4">
|
||||||
|
{typeof currentInfo === "string" ? (
|
||||||
|
<span className="font-semibold" data-testid="current-info">{currentInfo}</span>
|
||||||
|
) : (
|
||||||
|
currentInfo
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-[100px] min-h-[25px] py-1"
|
||||||
|
onClick={handleToggle}
|
||||||
|
type={state ? "reset" : "button"}
|
||||||
|
data-testid="edit-button"
|
||||||
|
data-active={state}
|
||||||
|
>
|
||||||
|
{state ? "Cancel" : "Edit"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success state */}
|
||||||
|
<Disclosure>
|
||||||
|
<Disclosure.Panel
|
||||||
|
static
|
||||||
|
className={clx(
|
||||||
|
"transition-[max-height,opacity] duration-300 ease-in-out overflow-hidden",
|
||||||
|
{
|
||||||
|
"max-h-[1000px] opacity-100": isSuccess,
|
||||||
|
"max-h-0 opacity-0": !isSuccess,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
data-testid="success-message"
|
||||||
|
>
|
||||||
|
<Badge className="p-2 my-4" color="green">
|
||||||
|
<span>{label} updated succesfully</span>
|
||||||
|
</Badge>
|
||||||
|
</Disclosure.Panel>
|
||||||
|
</Disclosure>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
<Disclosure>
|
||||||
|
<Disclosure.Panel
|
||||||
|
static
|
||||||
|
className={clx(
|
||||||
|
"transition-[max-height,opacity] duration-300 ease-in-out overflow-hidden",
|
||||||
|
{
|
||||||
|
"max-h-[1000px] opacity-100": isError,
|
||||||
|
"max-h-0 opacity-0": !isError,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
data-testid="error-message"
|
||||||
|
>
|
||||||
|
<Badge className="p-2 my-4" color="red">
|
||||||
|
<span>{errorMessage}</span>
|
||||||
|
</Badge>
|
||||||
|
</Disclosure.Panel>
|
||||||
|
</Disclosure>
|
||||||
|
|
||||||
|
<Disclosure>
|
||||||
|
<Disclosure.Panel
|
||||||
|
static
|
||||||
|
className={clx(
|
||||||
|
"transition-[max-height,opacity] duration-300 ease-in-out overflow-visible",
|
||||||
|
{
|
||||||
|
"max-h-[1000px] opacity-100": state,
|
||||||
|
"max-h-0 opacity-0": !state,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-y-2 py-4">
|
||||||
|
<div>{children}</div>
|
||||||
|
<div className="flex items-center justify-end mt-2">
|
||||||
|
<Button
|
||||||
|
isLoading={pending}
|
||||||
|
className="w-full small:max-w-[140px]"
|
||||||
|
type="submit"
|
||||||
|
data-testid="save-button"
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Disclosure.Panel>
|
||||||
|
</Disclosure>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccountInfo
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { clx } from "@medusajs/ui"
|
||||||
|
import { ArrowRightOnRectangle } from "@medusajs/icons"
|
||||||
|
import { useParams, usePathname } from "next/navigation"
|
||||||
|
|
||||||
|
import ChevronDown from "@modules/common/icons/chevron-down"
|
||||||
|
import User from "@modules/common/icons/user"
|
||||||
|
import MapPin from "@modules/common/icons/map-pin"
|
||||||
|
import Package from "@modules/common/icons/package"
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { signout } from "@lib/data/customer"
|
||||||
|
|
||||||
|
const AccountNav = ({
|
||||||
|
customer,
|
||||||
|
}: {
|
||||||
|
customer: HttpTypes.StoreCustomer | null
|
||||||
|
}) => {
|
||||||
|
const route = usePathname()
|
||||||
|
const { countryCode } = useParams() as { countryCode: string }
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await signout(countryCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="small:hidden" data-testid="mobile-account-nav">
|
||||||
|
{route !== `/${countryCode}/account` ? (
|
||||||
|
<LocalizedClientLink
|
||||||
|
href="/account"
|
||||||
|
className="flex items-center gap-x-2 text-small-regular py-2"
|
||||||
|
data-testid="account-main-link"
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<ChevronDown className="transform rotate-90" />
|
||||||
|
<span>Account</span>
|
||||||
|
</>
|
||||||
|
</LocalizedClientLink>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-xl-semi mb-4 px-8">
|
||||||
|
Hello {customer?.first_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-base-regular">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<LocalizedClientLink
|
||||||
|
href="/account/profile"
|
||||||
|
className="flex items-center justify-between py-4 border-b border-gray-200 px-8"
|
||||||
|
data-testid="profile-link"
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<User size={20} />
|
||||||
|
<span>Profile</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="transform -rotate-90" />
|
||||||
|
</>
|
||||||
|
</LocalizedClientLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<LocalizedClientLink
|
||||||
|
href="/account/addresses"
|
||||||
|
className="flex items-center justify-between py-4 border-b border-gray-200 px-8"
|
||||||
|
data-testid="addresses-link"
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<MapPin size={20} />
|
||||||
|
<span>Addresses</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="transform -rotate-90" />
|
||||||
|
</>
|
||||||
|
</LocalizedClientLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<LocalizedClientLink
|
||||||
|
href="/account/orders"
|
||||||
|
className="flex items-center justify-between py-4 border-b border-gray-200 px-8"
|
||||||
|
data-testid="orders-link"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<Package size={20} />
|
||||||
|
<span>Orders</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="transform -rotate-90" />
|
||||||
|
</LocalizedClientLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center justify-between py-4 border-b border-gray-200 px-8 w-full"
|
||||||
|
onClick={handleLogout}
|
||||||
|
data-testid="logout-button"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<ArrowRightOnRectangle />
|
||||||
|
<span>Log out</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="transform -rotate-90" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="hidden small:block" data-testid="account-nav">
|
||||||
|
<div>
|
||||||
|
<div className="pb-4">
|
||||||
|
<h3 className="text-base-semi">Account</h3>
|
||||||
|
</div>
|
||||||
|
<div className="text-base-regular">
|
||||||
|
<ul className="flex mb-0 justify-start items-start flex-col gap-y-4">
|
||||||
|
<li>
|
||||||
|
<AccountNavLink
|
||||||
|
href="/account"
|
||||||
|
route={route!}
|
||||||
|
data-testid="overview-link"
|
||||||
|
>
|
||||||
|
Overview
|
||||||
|
</AccountNavLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<AccountNavLink
|
||||||
|
href="/account/profile"
|
||||||
|
route={route!}
|
||||||
|
data-testid="profile-link"
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</AccountNavLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<AccountNavLink
|
||||||
|
href="/account/addresses"
|
||||||
|
route={route!}
|
||||||
|
data-testid="addresses-link"
|
||||||
|
>
|
||||||
|
Addresses
|
||||||
|
</AccountNavLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<AccountNavLink
|
||||||
|
href="/account/orders"
|
||||||
|
route={route!}
|
||||||
|
data-testid="orders-link"
|
||||||
|
>
|
||||||
|
Orders
|
||||||
|
</AccountNavLink>
|
||||||
|
</li>
|
||||||
|
<li className="text-grey-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
data-testid="logout-button"
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountNavLinkProps = {
|
||||||
|
href: string
|
||||||
|
route: string
|
||||||
|
children: React.ReactNode
|
||||||
|
"data-testid"?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccountNavLink = ({
|
||||||
|
href,
|
||||||
|
route,
|
||||||
|
children,
|
||||||
|
"data-testid": dataTestId,
|
||||||
|
}: AccountNavLinkProps) => {
|
||||||
|
const { countryCode }: { countryCode: string } = useParams()
|
||||||
|
|
||||||
|
const active = route.split(countryCode)[1] === href
|
||||||
|
return (
|
||||||
|
<LocalizedClientLink
|
||||||
|
href={href}
|
||||||
|
className={clx("text-ui-fg-subtle hover:text-ui-fg-base", {
|
||||||
|
"text-ui-fg-base font-semibold": active,
|
||||||
|
})}
|
||||||
|
data-testid={dataTestId}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</LocalizedClientLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccountNav
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
import AddAddress from "../address-card/add-address"
|
||||||
|
import EditAddress from "../address-card/edit-address-modal"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
|
||||||
|
type AddressBookProps = {
|
||||||
|
customer: HttpTypes.StoreCustomer
|
||||||
|
region: HttpTypes.StoreRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddressBook: React.FC<AddressBookProps> = ({ customer, region }) => {
|
||||||
|
const { addresses } = customer
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1 mt-4">
|
||||||
|
<AddAddress region={region} addresses={addresses} />
|
||||||
|
{addresses.map((address) => {
|
||||||
|
return (
|
||||||
|
<EditAddress region={region} address={address} key={address.id} />
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddressBook
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Plus } from "@medusajs/icons"
|
||||||
|
import { Button, Heading } from "@medusajs/ui"
|
||||||
|
import { useEffect, useState, useActionState } from "react"
|
||||||
|
|
||||||
|
import useToggleState from "@lib/hooks/use-toggle-state"
|
||||||
|
import CountrySelect from "@modules/checkout/components/country-select"
|
||||||
|
import Input from "@modules/common/components/input"
|
||||||
|
import Modal from "@modules/common/components/modal"
|
||||||
|
import { SubmitButton } from "@modules/checkout/components/submit-button"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { addCustomerAddress } from "@lib/data/customer"
|
||||||
|
|
||||||
|
const AddAddress = ({
|
||||||
|
region,
|
||||||
|
addresses,
|
||||||
|
}: {
|
||||||
|
region: HttpTypes.StoreRegion
|
||||||
|
addresses: HttpTypes.StoreCustomerAddress[]
|
||||||
|
}) => {
|
||||||
|
const [successState, setSuccessState] = useState(false)
|
||||||
|
const { state, open, close: closeModal } = useToggleState(false)
|
||||||
|
|
||||||
|
const [formState, formAction] = useActionState(addCustomerAddress, {
|
||||||
|
isDefaultShipping: addresses.length === 0,
|
||||||
|
success: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setSuccessState(false)
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (successState) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [successState])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (formState.success) {
|
||||||
|
setSuccessState(true)
|
||||||
|
}
|
||||||
|
}, [formState])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="border border-ui-border-base rounded-rounded p-5 min-h-[220px] h-full w-full flex flex-col justify-between"
|
||||||
|
onClick={open}
|
||||||
|
data-testid="add-address-button"
|
||||||
|
>
|
||||||
|
<span className="text-base-semi">New address</span>
|
||||||
|
<Plus />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Modal isOpen={state} close={close} data-testid="add-address-modal">
|
||||||
|
<Modal.Title>
|
||||||
|
<Heading className="mb-2">Add address</Heading>
|
||||||
|
</Modal.Title>
|
||||||
|
<form action={formAction}>
|
||||||
|
<Modal.Body>
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-x-2">
|
||||||
|
<Input
|
||||||
|
label="First name"
|
||||||
|
name="first_name"
|
||||||
|
required
|
||||||
|
autoComplete="given-name"
|
||||||
|
data-testid="first-name-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Last name"
|
||||||
|
name="last_name"
|
||||||
|
required
|
||||||
|
autoComplete="family-name"
|
||||||
|
data-testid="last-name-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Company"
|
||||||
|
name="company"
|
||||||
|
autoComplete="organization"
|
||||||
|
data-testid="company-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Address"
|
||||||
|
name="address_1"
|
||||||
|
required
|
||||||
|
autoComplete="address-line1"
|
||||||
|
data-testid="address-1-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Apartment, suite, etc."
|
||||||
|
name="address_2"
|
||||||
|
autoComplete="address-line2"
|
||||||
|
data-testid="address-2-input"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-[144px_1fr] gap-x-2">
|
||||||
|
<Input
|
||||||
|
label="Postal code"
|
||||||
|
name="postal_code"
|
||||||
|
required
|
||||||
|
autoComplete="postal-code"
|
||||||
|
data-testid="postal-code-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="City"
|
||||||
|
name="city"
|
||||||
|
required
|
||||||
|
autoComplete="locality"
|
||||||
|
data-testid="city-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Province / State"
|
||||||
|
name="province"
|
||||||
|
autoComplete="address-level1"
|
||||||
|
data-testid="state-input"
|
||||||
|
/>
|
||||||
|
<CountrySelect
|
||||||
|
region={region}
|
||||||
|
name="country_code"
|
||||||
|
required
|
||||||
|
autoComplete="country"
|
||||||
|
data-testid="country-select"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Phone"
|
||||||
|
name="phone"
|
||||||
|
autoComplete="phone"
|
||||||
|
data-testid="phone-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{formState.error && (
|
||||||
|
<div
|
||||||
|
className="text-rose-500 text-small-regular py-2"
|
||||||
|
data-testid="address-error"
|
||||||
|
>
|
||||||
|
{formState.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<Button
|
||||||
|
type="reset"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={close}
|
||||||
|
className="h-10"
|
||||||
|
data-testid="cancel-button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<SubmitButton data-testid="save-button">Save</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</Modal.Footer>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddAddress
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useActionState } from "react"
|
||||||
|
import { PencilSquare as Edit, Trash } from "@medusajs/icons"
|
||||||
|
import { Button, Heading, Text, clx } from "@medusajs/ui"
|
||||||
|
|
||||||
|
import useToggleState from "@lib/hooks/use-toggle-state"
|
||||||
|
import CountrySelect from "@modules/checkout/components/country-select"
|
||||||
|
import Input from "@modules/common/components/input"
|
||||||
|
import Modal from "@modules/common/components/modal"
|
||||||
|
import Spinner from "@modules/common/icons/spinner"
|
||||||
|
import { SubmitButton } from "@modules/checkout/components/submit-button"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import {
|
||||||
|
deleteCustomerAddress,
|
||||||
|
updateCustomerAddress,
|
||||||
|
} from "@lib/data/customer"
|
||||||
|
|
||||||
|
type EditAddressProps = {
|
||||||
|
region: HttpTypes.StoreRegion
|
||||||
|
address: HttpTypes.StoreCustomerAddress
|
||||||
|
isActive?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditAddress: React.FC<EditAddressProps> = ({
|
||||||
|
region,
|
||||||
|
address,
|
||||||
|
isActive = false,
|
||||||
|
}) => {
|
||||||
|
const [removing, setRemoving] = useState(false)
|
||||||
|
const [successState, setSuccessState] = useState(false)
|
||||||
|
const { state, open, close: closeModal } = useToggleState(false)
|
||||||
|
|
||||||
|
const [formState, formAction] = useActionState(updateCustomerAddress, {
|
||||||
|
success: false,
|
||||||
|
error: null,
|
||||||
|
addressId: address.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setSuccessState(false)
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (successState) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [successState])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (formState.success) {
|
||||||
|
setSuccessState(true)
|
||||||
|
}
|
||||||
|
}, [formState])
|
||||||
|
|
||||||
|
const removeAddress = async () => {
|
||||||
|
setRemoving(true)
|
||||||
|
await deleteCustomerAddress(address.id)
|
||||||
|
setRemoving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={clx(
|
||||||
|
"border rounded-rounded p-5 min-h-[220px] h-full w-full flex flex-col justify-between transition-colors",
|
||||||
|
{
|
||||||
|
"border-gray-900": isActive,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
data-testid="address-container"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Heading
|
||||||
|
className="text-left text-base-semi"
|
||||||
|
data-testid="address-name"
|
||||||
|
>
|
||||||
|
{address.first_name} {address.last_name}
|
||||||
|
</Heading>
|
||||||
|
{address.company && (
|
||||||
|
<Text
|
||||||
|
className="txt-compact-small text-ui-fg-base"
|
||||||
|
data-testid="address-company"
|
||||||
|
>
|
||||||
|
{address.company}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text className="flex flex-col text-left text-base-regular mt-2">
|
||||||
|
<span data-testid="address-address">
|
||||||
|
{address.address_1}
|
||||||
|
{address.address_2 && <span>, {address.address_2}</span>}
|
||||||
|
</span>
|
||||||
|
<span data-testid="address-postal-city">
|
||||||
|
{address.postal_code}, {address.city}
|
||||||
|
</span>
|
||||||
|
<span data-testid="address-province-country">
|
||||||
|
{address.province && `${address.province}, `}
|
||||||
|
{address.country_code?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<button
|
||||||
|
className="text-small-regular text-ui-fg-base flex items-center gap-x-2"
|
||||||
|
onClick={open}
|
||||||
|
data-testid="address-edit-button"
|
||||||
|
>
|
||||||
|
<Edit />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="text-small-regular text-ui-fg-base flex items-center gap-x-2"
|
||||||
|
onClick={removeAddress}
|
||||||
|
data-testid="address-delete-button"
|
||||||
|
>
|
||||||
|
{removing ? <Spinner /> : <Trash />}
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal isOpen={state} close={close} data-testid="edit-address-modal">
|
||||||
|
<Modal.Title>
|
||||||
|
<Heading className="mb-2">Edit address</Heading>
|
||||||
|
</Modal.Title>
|
||||||
|
<form action={formAction}>
|
||||||
|
<input type="hidden" name="addressId" value={address.id} />
|
||||||
|
<Modal.Body>
|
||||||
|
<div className="grid grid-cols-1 gap-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-x-2">
|
||||||
|
<Input
|
||||||
|
label="First name"
|
||||||
|
name="first_name"
|
||||||
|
required
|
||||||
|
autoComplete="given-name"
|
||||||
|
defaultValue={address.first_name || undefined}
|
||||||
|
data-testid="first-name-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Last name"
|
||||||
|
name="last_name"
|
||||||
|
required
|
||||||
|
autoComplete="family-name"
|
||||||
|
defaultValue={address.last_name || undefined}
|
||||||
|
data-testid="last-name-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Company"
|
||||||
|
name="company"
|
||||||
|
autoComplete="organization"
|
||||||
|
defaultValue={address.company || undefined}
|
||||||
|
data-testid="company-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Address"
|
||||||
|
name="address_1"
|
||||||
|
required
|
||||||
|
autoComplete="address-line1"
|
||||||
|
defaultValue={address.address_1 || undefined}
|
||||||
|
data-testid="address-1-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Apartment, suite, etc."
|
||||||
|
name="address_2"
|
||||||
|
autoComplete="address-line2"
|
||||||
|
defaultValue={address.address_2 || undefined}
|
||||||
|
data-testid="address-2-input"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-[144px_1fr] gap-x-2">
|
||||||
|
<Input
|
||||||
|
label="Postal code"
|
||||||
|
name="postal_code"
|
||||||
|
required
|
||||||
|
autoComplete="postal-code"
|
||||||
|
defaultValue={address.postal_code || undefined}
|
||||||
|
data-testid="postal-code-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="City"
|
||||||
|
name="city"
|
||||||
|
required
|
||||||
|
autoComplete="locality"
|
||||||
|
defaultValue={address.city || undefined}
|
||||||
|
data-testid="city-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Province / State"
|
||||||
|
name="province"
|
||||||
|
autoComplete="address-level1"
|
||||||
|
defaultValue={address.province || undefined}
|
||||||
|
data-testid="state-input"
|
||||||
|
/>
|
||||||
|
<CountrySelect
|
||||||
|
name="country_code"
|
||||||
|
region={region}
|
||||||
|
required
|
||||||
|
autoComplete="country"
|
||||||
|
defaultValue={address.country_code || undefined}
|
||||||
|
data-testid="country-select"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Phone"
|
||||||
|
name="phone"
|
||||||
|
autoComplete="phone"
|
||||||
|
defaultValue={address.phone || undefined}
|
||||||
|
data-testid="phone-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{formState.error && (
|
||||||
|
<div className="text-rose-500 text-small-regular py-2">
|
||||||
|
{formState.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<Button
|
||||||
|
type="reset"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={close}
|
||||||
|
className="h-10"
|
||||||
|
data-testid="cancel-button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<SubmitButton data-testid="save-button">Save</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</Modal.Footer>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditAddress
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { login } from "@lib/data/customer"
|
||||||
|
import { LOGIN_VIEW } from "@modules/account/templates/login-template"
|
||||||
|
import ErrorMessage from "@modules/checkout/components/error-message"
|
||||||
|
import { SubmitButton } from "@modules/checkout/components/submit-button"
|
||||||
|
import Input from "@modules/common/components/input"
|
||||||
|
import { useActionState } from "react"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
setCurrentView: (view: LOGIN_VIEW) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Login = ({ setCurrentView }: Props) => {
|
||||||
|
const [message, formAction] = useActionState(login, null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="max-w-sm w-full flex flex-col items-center"
|
||||||
|
data-testid="login-page"
|
||||||
|
>
|
||||||
|
<h1 className="text-large-semi uppercase mb-6">Welcome back</h1>
|
||||||
|
<p className="text-center text-base-regular text-ui-fg-base mb-8">
|
||||||
|
Sign in to access an enhanced shopping experience.
|
||||||
|
</p>
|
||||||
|
<form className="w-full" action={formAction}>
|
||||||
|
<div className="flex flex-col w-full gap-y-2">
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
title="Enter a valid email address."
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
data-testid="email-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
data-testid="password-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ErrorMessage error={message} data-testid="login-error-message" />
|
||||||
|
<SubmitButton data-testid="sign-in-button" className="w-full mt-6">
|
||||||
|
Sign in
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
<span className="text-center text-ui-fg-base text-small-regular mt-6">
|
||||||
|
Not a member?{" "}
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentView(LOGIN_VIEW.REGISTER)}
|
||||||
|
className="underline"
|
||||||
|
data-testid="register-button"
|
||||||
|
>
|
||||||
|
Join us
|
||||||
|
</button>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { Button } from "@medusajs/ui"
|
||||||
|
import { useMemo } from "react"
|
||||||
|
|
||||||
|
import Thumbnail from "@modules/products/components/thumbnail"
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
import { convertToLocale } from "@lib/util/money"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
|
||||||
|
type OrderCardProps = {
|
||||||
|
order: HttpTypes.StoreOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
const OrderCard = ({ order }: OrderCardProps) => {
|
||||||
|
const numberOfLines = useMemo(() => {
|
||||||
|
return (
|
||||||
|
order.items?.reduce((acc, item) => {
|
||||||
|
return acc + item.quantity
|
||||||
|
}, 0) ?? 0
|
||||||
|
)
|
||||||
|
}, [order])
|
||||||
|
|
||||||
|
const numberOfProducts = useMemo(() => {
|
||||||
|
return order.items?.length ?? 0
|
||||||
|
}, [order])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white flex flex-col" data-testid="order-card">
|
||||||
|
<div className="uppercase text-large-semi mb-1">
|
||||||
|
#<span data-testid="order-display-id">{order.display_id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center divide-x divide-gray-200 text-small-regular text-ui-fg-base">
|
||||||
|
<span className="pr-2" data-testid="order-created-at">
|
||||||
|
{new Date(order.created_at).toDateString()}
|
||||||
|
</span>
|
||||||
|
<span className="px-2" data-testid="order-amount">
|
||||||
|
{convertToLocale({
|
||||||
|
amount: order.total,
|
||||||
|
currency_code: order.currency_code,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span className="pl-2">{`${numberOfLines} ${
|
||||||
|
numberOfLines > 1 ? "items" : "item"
|
||||||
|
}`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 small:grid-cols-4 gap-4 my-4">
|
||||||
|
{order.items?.slice(0, 3).map((i) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i.id}
|
||||||
|
className="flex flex-col gap-y-2"
|
||||||
|
data-testid="order-item"
|
||||||
|
>
|
||||||
|
<Thumbnail thumbnail={i.thumbnail} images={[]} size="full" />
|
||||||
|
<div className="flex items-center text-small-regular text-ui-fg-base">
|
||||||
|
<span
|
||||||
|
className="text-ui-fg-base font-semibold"
|
||||||
|
data-testid="item-title"
|
||||||
|
>
|
||||||
|
{i.title}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2">x</span>
|
||||||
|
<span data-testid="item-quantity">{i.quantity}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{numberOfProducts > 4 && (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center">
|
||||||
|
<span className="text-small-regular text-ui-fg-base">
|
||||||
|
+ {numberOfLines - 4}
|
||||||
|
</span>
|
||||||
|
<span className="text-small-regular text-ui-fg-base">more</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<LocalizedClientLink href={`/account/orders/details/${order.id}`}>
|
||||||
|
<Button data-testid="order-details-link" variant="secondary">
|
||||||
|
See details
|
||||||
|
</Button>
|
||||||
|
</LocalizedClientLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OrderCard
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@medusajs/ui"
|
||||||
|
|
||||||
|
import OrderCard from "../order-card"
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
|
||||||
|
const OrderOverview = ({ orders }: { orders: HttpTypes.StoreOrder[] }) => {
|
||||||
|
if (orders?.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-8 w-full">
|
||||||
|
{orders.map((o) => (
|
||||||
|
<div
|
||||||
|
key={o.id}
|
||||||
|
className="border-b border-gray-200 pb-6 last:pb-0 last:border-none"
|
||||||
|
>
|
||||||
|
<OrderCard order={o} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full flex flex-col items-center gap-y-4"
|
||||||
|
data-testid="no-orders-container"
|
||||||
|
>
|
||||||
|
<h2 className="text-large-semi">Nothing to see here</h2>
|
||||||
|
<p className="text-base-regular">
|
||||||
|
You don't have any orders yet, let us change that {":)"}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<LocalizedClientLink href="/" passHref>
|
||||||
|
<Button data-testid="continue-shopping-button">
|
||||||
|
Continue shopping
|
||||||
|
</Button>
|
||||||
|
</LocalizedClientLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OrderOverview
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
import { Container } from "@medusajs/ui"
|
||||||
|
|
||||||
|
import ChevronDown from "@modules/common/icons/chevron-down"
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
import { convertToLocale } from "@lib/util/money"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
|
||||||
|
type OverviewProps = {
|
||||||
|
customer: HttpTypes.StoreCustomer | null
|
||||||
|
orders: HttpTypes.StoreOrder[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const Overview = ({ customer, orders }: OverviewProps) => {
|
||||||
|
return (
|
||||||
|
<div data-testid="overview-page-wrapper">
|
||||||
|
<div className="hidden small:block">
|
||||||
|
<div className="text-xl-semi flex justify-between items-center mb-4">
|
||||||
|
<span data-testid="welcome-message" data-value={customer?.first_name}>
|
||||||
|
Hello {customer?.first_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-small-regular text-ui-fg-base">
|
||||||
|
Signed in as:{" "}
|
||||||
|
<span
|
||||||
|
className="font-semibold"
|
||||||
|
data-testid="customer-email"
|
||||||
|
data-value={customer?.email}
|
||||||
|
>
|
||||||
|
{customer?.email}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col py-8 border-t border-gray-200">
|
||||||
|
<div className="flex flex-col gap-y-4 h-full col-span-1 row-span-2 flex-1">
|
||||||
|
<div className="flex items-start gap-x-16 mb-6">
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<h3 className="text-large-semi">Profile</h3>
|
||||||
|
<div className="flex items-end gap-x-2">
|
||||||
|
<span
|
||||||
|
className="text-3xl-semi leading-none"
|
||||||
|
data-testid="customer-profile-completion"
|
||||||
|
data-value={getProfileCompletion(customer)}
|
||||||
|
>
|
||||||
|
{getProfileCompletion(customer)}%
|
||||||
|
</span>
|
||||||
|
<span className="uppercase text-base-regular text-ui-fg-subtle">
|
||||||
|
Completed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<h3 className="text-large-semi">Addresses</h3>
|
||||||
|
<div className="flex items-end gap-x-2">
|
||||||
|
<span
|
||||||
|
className="text-3xl-semi leading-none"
|
||||||
|
data-testid="addresses-count"
|
||||||
|
data-value={customer?.addresses?.length || 0}
|
||||||
|
>
|
||||||
|
{customer?.addresses?.length || 0}
|
||||||
|
</span>
|
||||||
|
<span className="uppercase text-base-regular text-ui-fg-subtle">
|
||||||
|
Saved
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<h3 className="text-large-semi">Recent orders</h3>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
className="flex flex-col gap-y-4"
|
||||||
|
data-testid="orders-wrapper"
|
||||||
|
>
|
||||||
|
{orders && orders.length > 0 ? (
|
||||||
|
orders.slice(0, 5).map((order) => {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={order.id}
|
||||||
|
data-testid="order-wrapper"
|
||||||
|
data-value={order.id}
|
||||||
|
>
|
||||||
|
<LocalizedClientLink
|
||||||
|
href={`/account/orders/details/${order.id}`}
|
||||||
|
>
|
||||||
|
<Container className="bg-gray-50 flex justify-between items-center p-4">
|
||||||
|
<div className="grid grid-cols-3 grid-rows-2 text-small-regular gap-x-4 flex-1">
|
||||||
|
<span className="font-semibold">Date placed</span>
|
||||||
|
<span className="font-semibold">
|
||||||
|
Order number
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">
|
||||||
|
Total amount
|
||||||
|
</span>
|
||||||
|
<span data-testid="order-created-date">
|
||||||
|
{new Date(order.created_at).toDateString()}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
data-testid="order-id"
|
||||||
|
data-value={order.display_id}
|
||||||
|
>
|
||||||
|
#{order.display_id}
|
||||||
|
</span>
|
||||||
|
<span data-testid="order-amount">
|
||||||
|
{convertToLocale({
|
||||||
|
amount: order.total,
|
||||||
|
currency_code: order.currency_code,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
data-testid="open-order-button"
|
||||||
|
>
|
||||||
|
<span className="sr-only">
|
||||||
|
Go to order #{order.display_id}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="-rotate-90" />
|
||||||
|
</button>
|
||||||
|
</Container>
|
||||||
|
</LocalizedClientLink>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span data-testid="no-orders-message">No recent orders</span>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProfileCompletion = (customer: HttpTypes.StoreCustomer | null) => {
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customer.email) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customer.first_name && customer.last_name) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customer.phone) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
const billingAddress = customer.addresses?.find(
|
||||||
|
(addr) => addr.is_default_billing
|
||||||
|
)
|
||||||
|
|
||||||
|
if (billingAddress) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
return (count / 4) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Overview
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useActionState } from "react"
|
||||||
|
|
||||||
|
import Input from "@modules/common/components/input"
|
||||||
|
import NativeSelect from "@modules/common/components/native-select"
|
||||||
|
|
||||||
|
import AccountInfo from "../account-info"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { addCustomerAddress, updateCustomerAddress } from "@lib/data/customer"
|
||||||
|
|
||||||
|
type MyInformationProps = {
|
||||||
|
customer: HttpTypes.StoreCustomer
|
||||||
|
regions: HttpTypes.StoreRegion[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileBillingAddress: React.FC<MyInformationProps> = ({
|
||||||
|
customer,
|
||||||
|
regions,
|
||||||
|
}) => {
|
||||||
|
const regionOptions = useMemo(() => {
|
||||||
|
return (
|
||||||
|
regions
|
||||||
|
?.map((region) => {
|
||||||
|
return region.countries?.map((country) => ({
|
||||||
|
value: country.iso_2,
|
||||||
|
label: country.display_name,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.flat() || []
|
||||||
|
)
|
||||||
|
}, [regions])
|
||||||
|
|
||||||
|
const [successState, setSuccessState] = React.useState(false)
|
||||||
|
|
||||||
|
const billingAddress = customer.addresses?.find(
|
||||||
|
(addr) => addr.is_default_billing
|
||||||
|
)
|
||||||
|
|
||||||
|
const initialState: Record<string, any> = {
|
||||||
|
isDefaultBilling: true,
|
||||||
|
isDefaultShipping: false,
|
||||||
|
error: false,
|
||||||
|
success: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (billingAddress) {
|
||||||
|
initialState.addressId = billingAddress.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const [state, formAction] = useActionState(
|
||||||
|
billingAddress ? updateCustomerAddress : addCustomerAddress,
|
||||||
|
initialState
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearState = () => {
|
||||||
|
setSuccessState(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSuccessState(state.success)
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
const currentInfo = useMemo(() => {
|
||||||
|
if (!billingAddress) {
|
||||||
|
return "No billing address"
|
||||||
|
}
|
||||||
|
|
||||||
|
const country =
|
||||||
|
regionOptions?.find(
|
||||||
|
(country) => country?.value === billingAddress.country_code
|
||||||
|
)?.label || billingAddress.country_code?.toUpperCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col font-semibold" data-testid="current-info">
|
||||||
|
<span>
|
||||||
|
{billingAddress.first_name} {billingAddress.last_name}
|
||||||
|
</span>
|
||||||
|
<span>{billingAddress.company}</span>
|
||||||
|
<span>
|
||||||
|
{billingAddress.address_1}
|
||||||
|
{billingAddress.address_2 ? `, ${billingAddress.address_2}` : ""}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{billingAddress.postal_code}, {billingAddress.city}
|
||||||
|
</span>
|
||||||
|
<span>{country}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}, [billingAddress, regionOptions])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} onReset={() => clearState()} className="w-full">
|
||||||
|
<input type="hidden" name="addressId" value={billingAddress?.id} />
|
||||||
|
<AccountInfo
|
||||||
|
label="Billing address"
|
||||||
|
currentInfo={currentInfo}
|
||||||
|
isSuccess={successState}
|
||||||
|
isError={!!state.error}
|
||||||
|
clearState={clearState}
|
||||||
|
data-testid="account-billing-address-editor"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-x-2">
|
||||||
|
<Input
|
||||||
|
label="First name"
|
||||||
|
name="first_name"
|
||||||
|
defaultValue={billingAddress?.first_name || undefined}
|
||||||
|
required
|
||||||
|
data-testid="billing-first-name-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Last name"
|
||||||
|
name="last_name"
|
||||||
|
defaultValue={billingAddress?.last_name || undefined}
|
||||||
|
required
|
||||||
|
data-testid="billing-last-name-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Company"
|
||||||
|
name="company"
|
||||||
|
defaultValue={billingAddress?.company || undefined}
|
||||||
|
data-testid="billing-company-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Phone"
|
||||||
|
name="phone"
|
||||||
|
type="phone"
|
||||||
|
autoComplete="phone"
|
||||||
|
required
|
||||||
|
defaultValue={billingAddress?.phone ?? customer?.phone ?? ""}
|
||||||
|
data-testid="billing-phone-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Address"
|
||||||
|
name="address_1"
|
||||||
|
defaultValue={billingAddress?.address_1 || undefined}
|
||||||
|
required
|
||||||
|
data-testid="billing-address-1-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Apartment, suite, etc."
|
||||||
|
name="address_2"
|
||||||
|
defaultValue={billingAddress?.address_2 || undefined}
|
||||||
|
data-testid="billing-address-2-input"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-[144px_1fr] gap-x-2">
|
||||||
|
<Input
|
||||||
|
label="Postal code"
|
||||||
|
name="postal_code"
|
||||||
|
defaultValue={billingAddress?.postal_code || undefined}
|
||||||
|
required
|
||||||
|
data-testid="billing-postcal-code-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="City"
|
||||||
|
name="city"
|
||||||
|
defaultValue={billingAddress?.city || undefined}
|
||||||
|
required
|
||||||
|
data-testid="billing-city-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Province"
|
||||||
|
name="province"
|
||||||
|
defaultValue={billingAddress?.province || undefined}
|
||||||
|
data-testid="billing-province-input"
|
||||||
|
/>
|
||||||
|
<NativeSelect
|
||||||
|
name="country_code"
|
||||||
|
defaultValue={billingAddress?.country_code || undefined}
|
||||||
|
required
|
||||||
|
data-testid="billing-country-code-select"
|
||||||
|
>
|
||||||
|
<option value="">-</option>
|
||||||
|
{regionOptions.map((option, i) => {
|
||||||
|
return (
|
||||||
|
<option key={i} value={option?.value}>
|
||||||
|
{option?.label}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</NativeSelect>
|
||||||
|
</div>
|
||||||
|
</AccountInfo>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfileBillingAddress
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useEffect, useActionState } from "react";
|
||||||
|
|
||||||
|
import Input from "@modules/common/components/input"
|
||||||
|
|
||||||
|
import AccountInfo from "../account-info"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
// import { updateCustomer } from "@lib/data/customer"
|
||||||
|
|
||||||
|
type MyInformationProps = {
|
||||||
|
customer: HttpTypes.StoreCustomer
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileEmail: React.FC<MyInformationProps> = ({ customer }) => {
|
||||||
|
const [successState, setSuccessState] = React.useState(false)
|
||||||
|
|
||||||
|
// TODO: It seems we don't support updating emails now?
|
||||||
|
const updateCustomerEmail = (
|
||||||
|
_currentState: Record<string, unknown>,
|
||||||
|
formData: FormData
|
||||||
|
) => {
|
||||||
|
const customer = {
|
||||||
|
email: formData.get("email") as string,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// await updateCustomer(customer)
|
||||||
|
return { success: true, error: null }
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.toString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [state, formAction] = useActionState(updateCustomerEmail, {
|
||||||
|
error: false,
|
||||||
|
success: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const clearState = () => {
|
||||||
|
setSuccessState(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSuccessState(state.success)
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="w-full">
|
||||||
|
<AccountInfo
|
||||||
|
label="Email"
|
||||||
|
currentInfo={`${customer.email}`}
|
||||||
|
isSuccess={successState}
|
||||||
|
isError={!!state.error}
|
||||||
|
errorMessage={state.error}
|
||||||
|
clearState={clearState}
|
||||||
|
data-testid="account-email-editor"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-y-2">
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
defaultValue={customer.email}
|
||||||
|
data-testid="email-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AccountInfo>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfileEmail
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useEffect, useActionState } from "react";
|
||||||
|
|
||||||
|
import Input from "@modules/common/components/input"
|
||||||
|
|
||||||
|
import AccountInfo from "../account-info"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { updateCustomer } from "@lib/data/customer"
|
||||||
|
|
||||||
|
type MyInformationProps = {
|
||||||
|
customer: HttpTypes.StoreCustomer
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileName: React.FC<MyInformationProps> = ({ customer }) => {
|
||||||
|
const [successState, setSuccessState] = React.useState(false)
|
||||||
|
|
||||||
|
const updateCustomerName = async (
|
||||||
|
_currentState: Record<string, unknown>,
|
||||||
|
formData: FormData
|
||||||
|
) => {
|
||||||
|
const customer = {
|
||||||
|
first_name: formData.get("first_name") as string,
|
||||||
|
last_name: formData.get("last_name") as string,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateCustomer(customer)
|
||||||
|
return { success: true, error: null }
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.toString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [state, formAction] = useActionState(updateCustomerName, {
|
||||||
|
error: false,
|
||||||
|
success: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const clearState = () => {
|
||||||
|
setSuccessState(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSuccessState(state.success)
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="w-full overflow-visible">
|
||||||
|
<AccountInfo
|
||||||
|
label="Name"
|
||||||
|
currentInfo={`${customer.first_name} ${customer.last_name}`}
|
||||||
|
isSuccess={successState}
|
||||||
|
isError={!!state?.error}
|
||||||
|
clearState={clearState}
|
||||||
|
data-testid="account-name-editor"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4">
|
||||||
|
<Input
|
||||||
|
label="First name"
|
||||||
|
name="first_name"
|
||||||
|
required
|
||||||
|
defaultValue={customer.first_name ?? ""}
|
||||||
|
data-testid="first-name-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Last name"
|
||||||
|
name="last_name"
|
||||||
|
required
|
||||||
|
defaultValue={customer.last_name ?? ""}
|
||||||
|
data-testid="last-name-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AccountInfo>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfileName
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useEffect, useActionState } from "react"
|
||||||
|
import Input from "@modules/common/components/input"
|
||||||
|
import AccountInfo from "../account-info"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { toast } from "@medusajs/ui"
|
||||||
|
|
||||||
|
type MyInformationProps = {
|
||||||
|
customer: HttpTypes.StoreCustomer
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfilePassword: React.FC<MyInformationProps> = ({ customer }) => {
|
||||||
|
const [successState, setSuccessState] = React.useState(false)
|
||||||
|
|
||||||
|
// TODO: Add support for password updates
|
||||||
|
const updatePassword = async () => {
|
||||||
|
toast.info("Password update is not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearState = () => {
|
||||||
|
setSuccessState(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={updatePassword}
|
||||||
|
onReset={() => clearState()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<AccountInfo
|
||||||
|
label="Password"
|
||||||
|
currentInfo={
|
||||||
|
<span>The password is not shown for security reasons</span>
|
||||||
|
}
|
||||||
|
isSuccess={successState}
|
||||||
|
isError={false}
|
||||||
|
errorMessage={undefined}
|
||||||
|
clearState={clearState}
|
||||||
|
data-testid="account-password-editor"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Old password"
|
||||||
|
name="old_password"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
data-testid="old-password-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="New password"
|
||||||
|
type="password"
|
||||||
|
name="new_password"
|
||||||
|
required
|
||||||
|
data-testid="new-password-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Confirm password"
|
||||||
|
type="password"
|
||||||
|
name="confirm_password"
|
||||||
|
required
|
||||||
|
data-testid="confirm-password-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AccountInfo>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfilePassword
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useEffect, useActionState } from "react";
|
||||||
|
|
||||||
|
import Input from "@modules/common/components/input"
|
||||||
|
|
||||||
|
import AccountInfo from "../account-info"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { updateCustomer } from "@lib/data/customer"
|
||||||
|
|
||||||
|
type MyInformationProps = {
|
||||||
|
customer: HttpTypes.StoreCustomer
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileEmail: React.FC<MyInformationProps> = ({ customer }) => {
|
||||||
|
const [successState, setSuccessState] = React.useState(false)
|
||||||
|
|
||||||
|
const updateCustomerPhone = async (
|
||||||
|
_currentState: Record<string, unknown>,
|
||||||
|
formData: FormData
|
||||||
|
) => {
|
||||||
|
const customer = {
|
||||||
|
phone: formData.get("phone") as string,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateCustomer(customer)
|
||||||
|
return { success: true, error: null }
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.toString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [state, formAction] = useActionState(updateCustomerPhone, {
|
||||||
|
error: false,
|
||||||
|
success: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const clearState = () => {
|
||||||
|
setSuccessState(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSuccessState(state.success)
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="w-full">
|
||||||
|
<AccountInfo
|
||||||
|
label="Phone"
|
||||||
|
currentInfo={`${customer.phone}`}
|
||||||
|
isSuccess={successState}
|
||||||
|
isError={!!state.error}
|
||||||
|
errorMessage={state.error}
|
||||||
|
clearState={clearState}
|
||||||
|
data-testid="account-phone-editor"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-y-2">
|
||||||
|
<Input
|
||||||
|
label="Phone"
|
||||||
|
name="phone"
|
||||||
|
type="phone"
|
||||||
|
autoComplete="phone"
|
||||||
|
required
|
||||||
|
defaultValue={customer.phone ?? ""}
|
||||||
|
data-testid="phone-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AccountInfo>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfileEmail
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useActionState } from "react"
|
||||||
|
import Input from "@modules/common/components/input"
|
||||||
|
import { LOGIN_VIEW } from "@modules/account/templates/login-template"
|
||||||
|
import ErrorMessage from "@modules/checkout/components/error-message"
|
||||||
|
import { SubmitButton } from "@modules/checkout/components/submit-button"
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
import { signup } from "@lib/data/customer"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
setCurrentView: (view: LOGIN_VIEW) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Register = ({ setCurrentView }: Props) => {
|
||||||
|
const [message, formAction] = useActionState(signup, null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="max-w-sm flex flex-col items-center"
|
||||||
|
data-testid="register-page"
|
||||||
|
>
|
||||||
|
<h1 className="text-large-semi uppercase mb-6">
|
||||||
|
Become a Medusa Store Member
|
||||||
|
</h1>
|
||||||
|
<p className="text-center text-base-regular text-ui-fg-base mb-4">
|
||||||
|
Create your Medusa Store Member profile, and get access to an enhanced
|
||||||
|
shopping experience.
|
||||||
|
</p>
|
||||||
|
<form className="w-full flex flex-col" action={formAction}>
|
||||||
|
<div className="flex flex-col w-full gap-y-2">
|
||||||
|
<Input
|
||||||
|
label="First name"
|
||||||
|
name="first_name"
|
||||||
|
required
|
||||||
|
autoComplete="given-name"
|
||||||
|
data-testid="first-name-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Last name"
|
||||||
|
name="last_name"
|
||||||
|
required
|
||||||
|
autoComplete="family-name"
|
||||||
|
data-testid="last-name-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
data-testid="email-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Phone"
|
||||||
|
name="phone"
|
||||||
|
type="tel"
|
||||||
|
autoComplete="tel"
|
||||||
|
data-testid="phone-input"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
data-testid="password-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ErrorMessage error={message} data-testid="register-error" />
|
||||||
|
<span className="text-center text-ui-fg-base text-small-regular mt-6">
|
||||||
|
By creating an account, you agree to Medusa Store's{" "}
|
||||||
|
<LocalizedClientLink
|
||||||
|
href="/content/privacy-policy"
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</LocalizedClientLink>{" "}
|
||||||
|
and{" "}
|
||||||
|
<LocalizedClientLink
|
||||||
|
href="/content/terms-of-use"
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
Terms of Use
|
||||||
|
</LocalizedClientLink>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
<SubmitButton className="w-full mt-6" data-testid="register-button">
|
||||||
|
Join
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
<span className="text-center text-ui-fg-base text-small-regular mt-6">
|
||||||
|
Already a member?{" "}
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentView(LOGIN_VIEW.SIGN_IN)}
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Register
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useActionState } from "react"
|
||||||
|
import { createTransferRequest } from "@lib/data/orders"
|
||||||
|
import { Text, Heading, Input, Button, IconButton, Toaster } from "@medusajs/ui"
|
||||||
|
import { SubmitButton } from "@modules/checkout/components/submit-button"
|
||||||
|
import { CheckCircleMiniSolid, XCircleSolid } from "@medusajs/icons"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
export default function TransferRequestForm() {
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false)
|
||||||
|
|
||||||
|
const [state, formAction] = useActionState(createTransferRequest, {
|
||||||
|
success: false,
|
||||||
|
error: null,
|
||||||
|
order: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.success && state.order) {
|
||||||
|
setShowSuccess(true)
|
||||||
|
}
|
||||||
|
}, [state.success, state.order])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-4 w-full">
|
||||||
|
<div className="grid sm:grid-cols-2 items-center gap-x-8 gap-y-4 w-full">
|
||||||
|
<div className="flex flex-col gap-y-1">
|
||||||
|
<Heading level="h3" className="text-lg text-neutral-950">
|
||||||
|
Order transfers
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-base-regular text-neutral-500">
|
||||||
|
Can't find the order you are looking for?
|
||||||
|
<br /> Connect an order to your account.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
className="flex flex-col gap-y-1 sm:items-end"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-y-2 w-full">
|
||||||
|
<Input className="w-full" name="order_id" placeholder="Order ID" />
|
||||||
|
<SubmitButton
|
||||||
|
variant="secondary"
|
||||||
|
className="w-fit whitespace-nowrap self-end"
|
||||||
|
>
|
||||||
|
Request transfer
|
||||||
|
</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{!state.success && state.error && (
|
||||||
|
<Text className="text-base-regular text-rose-500 text-right">
|
||||||
|
{state.error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{showSuccess && (
|
||||||
|
<div className="flex justify-between p-4 bg-neutral-50 shadow-borders-base w-full self-stretch items-center">
|
||||||
|
<div className="flex gap-x-2 items-center">
|
||||||
|
<CheckCircleMiniSolid className="w-4 h-4 text-emerald-500" />
|
||||||
|
<div className="flex flex-col gap-y-1">
|
||||||
|
<Text className="text-medim-pl text-neutral-950">
|
||||||
|
Transfer for order {state.order?.id} requested
|
||||||
|
</Text>
|
||||||
|
<Text className="text-base-regular text-neutral-600">
|
||||||
|
Transfer request email sent to {state.order?.email}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
variant="transparent"
|
||||||
|
className="h-fit"
|
||||||
|
onClick={() => setShowSuccess(false)}
|
||||||
|
>
|
||||||
|
<XCircleSolid className="w-4 h-4 text-neutral-500" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
import UnderlineLink from "@modules/common/components/interactive-link"
|
||||||
|
|
||||||
|
import AccountNav from "../components/account-nav"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
|
||||||
|
interface AccountLayoutProps {
|
||||||
|
customer: HttpTypes.StoreCustomer | null
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccountLayout: React.FC<AccountLayoutProps> = ({
|
||||||
|
customer,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 small:py-12" data-testid="account-page">
|
||||||
|
<div className="flex-1 content-container h-full max-w-5xl mx-auto bg-white flex flex-col">
|
||||||
|
<div className="grid grid-cols-1 small:grid-cols-[240px_1fr] py-12">
|
||||||
|
<div>{customer && <AccountNav customer={customer} />}</div>
|
||||||
|
<div className="flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col small:flex-row items-end justify-between small:border-t border-gray-200 py-12 gap-8">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl-semi mb-4">Got questions?</h3>
|
||||||
|
<span className="txt-medium">
|
||||||
|
You can find frequently asked questions and answers on our
|
||||||
|
customer service page.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<UnderlineLink href="/customer-service">
|
||||||
|
Customer Service
|
||||||
|
</UnderlineLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccountLayout
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import Register from "@modules/account/components/register"
|
||||||
|
import Login from "@modules/account/components/login"
|
||||||
|
|
||||||
|
export enum LOGIN_VIEW {
|
||||||
|
SIGN_IN = "sign-in",
|
||||||
|
REGISTER = "register",
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoginTemplate = () => {
|
||||||
|
const [currentView, setCurrentView] = useState("sign-in")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex justify-start px-8 py-8">
|
||||||
|
{currentView === "sign-in" ? (
|
||||||
|
<Login setCurrentView={setCurrentView} />
|
||||||
|
) : (
|
||||||
|
<Register setCurrentView={setCurrentView} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginTemplate
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { IconBadge, clx } from "@medusajs/ui"
|
||||||
|
import {
|
||||||
|
SelectHTMLAttributes,
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
|
||||||
|
import ChevronDown from "@modules/common/icons/chevron-down"
|
||||||
|
|
||||||
|
type NativeSelectProps = {
|
||||||
|
placeholder?: string
|
||||||
|
errors?: Record<string, unknown>
|
||||||
|
touched?: Record<string, unknown>
|
||||||
|
} & Omit<SelectHTMLAttributes<HTMLSelectElement>, "size">
|
||||||
|
|
||||||
|
const CartItemSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
|
||||||
|
({ placeholder = "Select...", className, children, ...props }, ref) => {
|
||||||
|
const innerRef = useRef<HTMLSelectElement>(null)
|
||||||
|
const [isPlaceholder, setIsPlaceholder] = useState(false)
|
||||||
|
|
||||||
|
useImperativeHandle<HTMLSelectElement | null, HTMLSelectElement | null>(
|
||||||
|
ref,
|
||||||
|
() => innerRef.current
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (innerRef.current && innerRef.current.value === "") {
|
||||||
|
setIsPlaceholder(true)
|
||||||
|
} else {
|
||||||
|
setIsPlaceholder(false)
|
||||||
|
}
|
||||||
|
}, [innerRef.current?.value])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<IconBadge
|
||||||
|
onFocus={() => innerRef.current?.focus()}
|
||||||
|
onBlur={() => innerRef.current?.blur()}
|
||||||
|
className={clx(
|
||||||
|
"relative flex items-center txt-compact-small border text-ui-fg-base group",
|
||||||
|
className,
|
||||||
|
{
|
||||||
|
"text-ui-fg-subtle": isPlaceholder,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
ref={innerRef}
|
||||||
|
{...props}
|
||||||
|
className="appearance-none bg-transparent border-none px-4 transition-colors duration-150 focus:border-gray-700 outline-none w-16 h-16 items-center justify-center"
|
||||||
|
>
|
||||||
|
<option disabled value="">
|
||||||
|
{placeholder}
|
||||||
|
</option>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
<span className="absolute flex pointer-events-none justify-end w-8 group-hover:animate-pulse">
|
||||||
|
<ChevronDown />
|
||||||
|
</span>
|
||||||
|
</IconBadge>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
CartItemSelect.displayName = "CartItemSelect"
|
||||||
|
|
||||||
|
export default CartItemSelect
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { Heading, Text } from "@medusajs/ui"
|
||||||
|
|
||||||
|
import InteractiveLink from "@modules/common/components/interactive-link"
|
||||||
|
|
||||||
|
const EmptyCartMessage = () => {
|
||||||
|
return (
|
||||||
|
<div className="py-48 px-2 flex flex-col justify-center items-start" data-testid="empty-cart-message">
|
||||||
|
<Heading
|
||||||
|
level="h1"
|
||||||
|
className="flex flex-row text-3xl-regular gap-x-2 items-baseline"
|
||||||
|
>
|
||||||
|
Cart
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-base-regular mt-4 mb-6 max-w-[32rem]">
|
||||||
|
You don't have anything in your cart. Let'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
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Table, Text, clx } from "@medusajs/ui"
|
||||||
|
import { updateLineItem } from "@lib/data/cart"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import CartItemSelect from "@modules/cart/components/cart-item-select"
|
||||||
|
import ErrorMessage from "@modules/checkout/components/error-message"
|
||||||
|
import DeleteButton from "@modules/common/components/delete-button"
|
||||||
|
import LineItemOptions from "@modules/common/components/line-item-options"
|
||||||
|
import LineItemPrice from "@modules/common/components/line-item-price"
|
||||||
|
import LineItemUnitPrice from "@modules/common/components/line-item-unit-price"
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
import Spinner from "@modules/common/icons/spinner"
|
||||||
|
import Thumbnail from "@modules/products/components/thumbnail"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
type ItemProps = {
|
||||||
|
item: HttpTypes.StoreCartLineItem
|
||||||
|
type?: "full" | "preview"
|
||||||
|
currencyCode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Item = ({ item, type = "full", currencyCode }: ItemProps) => {
|
||||||
|
const [updating, setUpdating] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const changeQuantity = async (quantity: number) => {
|
||||||
|
setError(null)
|
||||||
|
setUpdating(true)
|
||||||
|
|
||||||
|
await updateLineItem({
|
||||||
|
lineId: item.id,
|
||||||
|
quantity,
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err.message)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setUpdating(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Update this to grab the actual max inventory
|
||||||
|
const maxQtyFromInventory = 10
|
||||||
|
const maxQuantity = item.variant?.manage_inventory ? 10 : maxQtyFromInventory
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Row className="w-full" data-testid="product-row">
|
||||||
|
<Table.Cell className="!pl-0 p-4 w-24">
|
||||||
|
<LocalizedClientLink
|
||||||
|
href={`/products/${item.product_handle}`}
|
||||||
|
className={clx("flex", {
|
||||||
|
"w-16": type === "preview",
|
||||||
|
"small:w-24 w-12": type === "full",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Thumbnail
|
||||||
|
thumbnail={item.thumbnail}
|
||||||
|
images={item.variant?.product?.images}
|
||||||
|
size="square"
|
||||||
|
/>
|
||||||
|
</LocalizedClientLink>
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
<Table.Cell className="text-left">
|
||||||
|
<Text
|
||||||
|
className="txt-medium-plus text-ui-fg-base"
|
||||||
|
data-testid="product-title"
|
||||||
|
>
|
||||||
|
{item.product_title}
|
||||||
|
</Text>
|
||||||
|
<LineItemOptions variant={item.variant} data-testid="product-variant" />
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
{type === "full" && (
|
||||||
|
<Table.Cell>
|
||||||
|
<div className="flex gap-2 items-center w-28">
|
||||||
|
<DeleteButton id={item.id} data-testid="product-delete-button" />
|
||||||
|
<CartItemSelect
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(value) => changeQuantity(parseInt(value.target.value))}
|
||||||
|
className="w-14 h-10 p-4"
|
||||||
|
data-testid="product-select-button"
|
||||||
|
>
|
||||||
|
{/* TODO: Update this with the v2 way of managing inventory */}
|
||||||
|
{Array.from(
|
||||||
|
{
|
||||||
|
length: Math.min(maxQuantity, 10),
|
||||||
|
},
|
||||||
|
(_, i) => (
|
||||||
|
<option value={i + 1} key={i}>
|
||||||
|
{i + 1}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<option value={1} key={1}>
|
||||||
|
1
|
||||||
|
</option>
|
||||||
|
</CartItemSelect>
|
||||||
|
{updating && <Spinner />}
|
||||||
|
</div>
|
||||||
|
<ErrorMessage error={error} data-testid="product-error-message" />
|
||||||
|
</Table.Cell>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === "full" && (
|
||||||
|
<Table.Cell className="hidden small:table-cell">
|
||||||
|
<LineItemUnitPrice
|
||||||
|
item={item}
|
||||||
|
style="tight"
|
||||||
|
currencyCode={currencyCode}
|
||||||
|
/>
|
||||||
|
</Table.Cell>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table.Cell className="!pr-0">
|
||||||
|
<span
|
||||||
|
className={clx("!pr-0", {
|
||||||
|
"flex flex-col items-end h-full justify-center": type === "preview",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{type === "preview" && (
|
||||||
|
<span className="flex gap-x-1 ">
|
||||||
|
<Text className="text-ui-fg-muted">{item.quantity}x </Text>
|
||||||
|
<LineItemUnitPrice
|
||||||
|
item={item}
|
||||||
|
style="tight"
|
||||||
|
currencyCode={currencyCode}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<LineItemPrice
|
||||||
|
item={item}
|
||||||
|
style="tight"
|
||||||
|
currencyCode={currencyCode}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Item
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Button, Heading, Text } from "@medusajs/ui"
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
|
||||||
|
const SignInPrompt = () => {
|
||||||
|
return (
|
||||||
|
<div className="bg-white flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Heading level="h2" className="txt-xlarge">
|
||||||
|
Already have an account?
|
||||||
|
</Heading>
|
||||||
|
<Text className="txt-medium text-ui-fg-subtle mt-2">
|
||||||
|
Sign in for a better experience.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<LocalizedClientLink href="/account">
|
||||||
|
<Button variant="secondary" className="h-10" data-testid="sign-in-button">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</LocalizedClientLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SignInPrompt
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button, Heading } from "@medusajs/ui"
|
||||||
|
|
||||||
|
import CartTotals from "@modules/common/components/cart-totals"
|
||||||
|
import Divider from "@modules/common/components/divider"
|
||||||
|
import DiscountCode from "@modules/checkout/components/discount-code"
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
|
||||||
|
type SummaryProps = {
|
||||||
|
cart: HttpTypes.StoreCart & {
|
||||||
|
promotions: HttpTypes.StorePromotion[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCheckoutStep(cart: HttpTypes.StoreCart) {
|
||||||
|
if (!cart?.shipping_address?.address_1 || !cart.email) {
|
||||||
|
return "address"
|
||||||
|
} else if (cart?.shipping_methods?.length === 0) {
|
||||||
|
return "delivery"
|
||||||
|
} else {
|
||||||
|
return "payment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Summary = ({ cart }: SummaryProps) => {
|
||||||
|
const step = getCheckoutStep(cart)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<Heading level="h2" className="text-[2rem] leading-[2.75rem]">
|
||||||
|
Summary
|
||||||
|
</Heading>
|
||||||
|
<DiscountCode cart={cart} />
|
||||||
|
<Divider />
|
||||||
|
<CartTotals totals={cart} />
|
||||||
|
<LocalizedClientLink
|
||||||
|
href={"/checkout?step=" + step}
|
||||||
|
data-testid="checkout-button"
|
||||||
|
>
|
||||||
|
<Button className="w-full h-10">Go to checkout</Button>
|
||||||
|
</LocalizedClientLink>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Summary
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { Suspense } from "react"
|
||||||
|
|
||||||
|
import InteractiveLink from "@modules/common/components/interactive-link"
|
||||||
|
import SkeletonProductGrid from "@modules/skeletons/templates/skeleton-product-grid"
|
||||||
|
import RefinementList from "@modules/store/components/refinement-list"
|
||||||
|
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
|
||||||
|
import PaginatedProducts from "@modules/store/templates/paginated-products"
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
|
||||||
|
export default function CategoryTemplate({
|
||||||
|
category,
|
||||||
|
sortBy,
|
||||||
|
page,
|
||||||
|
countryCode,
|
||||||
|
}: {
|
||||||
|
category: HttpTypes.StoreProductCategory
|
||||||
|
sortBy?: SortOptions
|
||||||
|
page?: string
|
||||||
|
countryCode: string
|
||||||
|
}) {
|
||||||
|
const pageNumber = page ? parseInt(page) : 1
|
||||||
|
const sort = sortBy || "created_at"
|
||||||
|
|
||||||
|
if (!category || !countryCode) notFound()
|
||||||
|
|
||||||
|
const parents = [] as HttpTypes.StoreProductCategory[]
|
||||||
|
|
||||||
|
const getParents = (category: HttpTypes.StoreProductCategory) => {
|
||||||
|
if (category.parent_category) {
|
||||||
|
parents.push(category.parent_category)
|
||||||
|
getParents(category.parent_category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getParents(category)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col small:flex-row small:items-start py-6 content-container"
|
||||||
|
data-testid="category-container"
|
||||||
|
>
|
||||||
|
<RefinementList sortBy={sort} data-testid="sort-by-container" />
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex flex-row mb-8 text-2xl-semi gap-4">
|
||||||
|
{parents &&
|
||||||
|
parents.map((parent) => (
|
||||||
|
<span key={parent.id} className="text-ui-fg-subtle">
|
||||||
|
<LocalizedClientLink
|
||||||
|
className="mr-4 hover:text-black"
|
||||||
|
href={`/categories/${parent.handle}`}
|
||||||
|
data-testid="sort-by-link"
|
||||||
|
>
|
||||||
|
{parent.name}
|
||||||
|
</LocalizedClientLink>
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<h1 data-testid="category-page-title">{category.name}</h1>
|
||||||
|
</div>
|
||||||
|
{category.description && (
|
||||||
|
<div className="mb-8 text-base-regular">
|
||||||
|
<p>{category.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{category.category_children && (
|
||||||
|
<div className="mb-8 text-base-large">
|
||||||
|
<ul className="grid grid-cols-1 gap-2">
|
||||||
|
{category.category_children?.map((c) => (
|
||||||
|
<li key={c.id}>
|
||||||
|
<InteractiveLink href={`/categories/${c.handle}`}>
|
||||||
|
{c.name}
|
||||||
|
</InteractiveLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<SkeletonProductGrid
|
||||||
|
numberOfProducts={category.products?.length ?? 8}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PaginatedProducts
|
||||||
|
sortBy={sort}
|
||||||
|
page={pageNumber}
|
||||||
|
categoryId={category.id}
|
||||||
|
countryCode={countryCode}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue