Implement Quote Management in Medusa

In this guide, you'll learn how to implement quote management in Medusa.

When you install a Medusa application, you get a fully-fledged commerce platform with a framework for customization. The Medusa application's commerce features are built around commerce modules which are available out-of-the-box.

By default, the Medusa application provides standard commerce features for orders and carts. However, Medusa's customization capabilities facilitate extending existing features to implement quote-management features.

By building quote management features, you allow customers to request a quote for a set of products and, once the merchant and customer reach an agreement, you create an order for that quote. Quote management is useful in many use cases, including B2B stores.

TipThis guide is based on the B2B starter explaining how to implement some of its quote management features. You can refer to the B2B starter for other features not covered in this guide.

Summary#

By following this guide, you'll add the following features to Medusa:

  1. Customers can request a quote for a set of products.
  2. Merchants can manage quotes in the Medusa Admin dashboard. They can reject a quote or send a counter-offer, and they can make edits to item prices and quantities.
  3. Customers can accept or reject a quote once it's been sent by the merchant.
  4. Once the customer accepts a quote, it's converted to an order in Medusa.

Diagram showcasing the features summary

To implement these features, you'll be customizing the Medusa server and the Medusa Admin dashboard.

You can follow this guide whether you're new to Medusa or an advanced Medusa developer.

Quote Management Repository
Find the full code for this guide in this repository.
OpenApi Specs for Postman
Import this OpenApi Specs file into tools like Postman.

Step 1: Install a Medusa Application#

Start by installing the Medusa application on your machine with the following command:

Terminal
npx create-medusa-app@latest

You'll first be asked for the project's name. You can also optionally choose to install the Next.js starter storefront.

Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name. If you chose to install the Next.js starter, it'll be installed in a separate directory with the {project-name}-storefront name.

Why is the storefront installed separatelyThe Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called API routes. Learn more about Medusa's architecture in this documentation.

Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credentials and submit the form. Afterwards, you can log in with the new user and explore the dashboard.

Ran into ErrorsCheck out the troubleshooting guides for help.

Step 2: Add Quote Module#

In Medusa, you can build custom features in a module. A module is a reusable package with functionalities related to a single feature or domain. Medusa integrates the module into your application without implications or side effects on your setup.

In the module, you define the data models necessary for a feature and the logic to manage these data models. Later, you can build commerce flows around your module and link its data models to other modules' data models, such as orders and carts.

In this step, you'll build a Quote Module that defines the necessary data model to store quotes.

NoteLearn more about modules in this documentation.

Create Module Directory#

A module is created under the src/modules directory of your Medusa application. So, create the directory src/modules/quote.

Diagram showcasing the directory structure after adding the Quote Module's directory

Create Data Models#

A data model represents a table in the database. You create data models using Medusa's Data Model Language (DML). It simplifies defining a table's columns, relations, and indexes with straightforward methods and configurations.

NoteLearn more about data models in this documentation.

For the Quote Module, you need to define a Quote data model that represents a quote requested by a customer.

So, start by creating the Quote data model. Create the file src/modules/quote/models/quote.ts with the following content:

Diagram showcasing the directory structure after adding the quote model

src/modules/quote/models/quote.ts
1import { model } from "@medusajs/framework/utils"2
3export enum QuoteStatus {4  PENDING_MERCHANT = "pending_merchant",5  PENDING_CUSTOMER = "pending_customer",6  ACCEPTED = "accepted",7  CUSTOMER_REJECTED = "customer_rejected",8  MERCHANT_REJECTED = "merchant_rejected",9}10
11export const Quote = model.define("quote", {12  id: model.id().primaryKey(),13  status: model14    .enum(Object.values(QuoteStatus))15    .default(QuoteStatus.PENDING_MERCHANT),16  customer_id: model.text(),17  draft_order_id: model.text(),18  order_change_id: model.text(),19  cart_id: model.text(),20})

You define the Quote data model using the model.define method of the DML. It accepts the data model's table name as a first parameter, and the model's schema object as a second parameter.

Quote has the following properties:

  • id: A unique identifier for the quote.
  • status: The status of the quote, which can be one of the following:
    • pending_merchant: The quote is pending the merchant's approval or rejection.
    • pending_customer: The quote is pending the customer's acceptance or rejection.
    • accepted: The quote has been accepted by the customer and converted to an order.
    • customer_rejected: The customer has rejected the quote.
    • merchant_rejected: The merchant has rejected the quote.
  • customer_id: The ID of the customer who requested the quote. You'll later learn how to link this to a customer record.
  • draft_order_id: The ID of the draft order created for the quote. You'll later learn how to link this to an order record.
  • order_change_id: The ID of the order change created for the quote. An order change is a record of changes made to an order, such as price or quantity updates of the order's items. These changes are later applied to the order. You'll later learn how to link this to an order change record.
  • cart_id: The ID of the cart that the quote was created from. The cart will hold the items that the customer wants a quote for. You'll later learn how to link this to a cart record.
NoteLearn more about defining data model properties in the Property Types documentation.

Create Module's Service#

You now have the necessary data model in the Quote Module, but you need to define the logic to manage it. You do this by creating a service in the module.

A service is a TypeScript or JavaScript class that the module exports. In the service's methods, you can connect to the database, allowing you to manage your data models, or connect to a third-party service, which is useful if you're integrating with external services.

NoteLearn more about services in this documentation.

To create the Quote Module's service, create the file src/modules/quote/service.ts with the following content:

Directory structure after adding the service

src/modules/quote/service.ts
1import { MedusaService } from "@medusajs/framework/utils"2import { Quote } from "./models/quote"3
4class QuoteModuleService extends MedusaService({ 5  Quote, 6}) {}7
8export default QuoteModuleService

The QuoteModuleService extends MedusaService from the Modules SDK which generates a class with data-management methods for your module's data models. This saves you time on implementing Create, Read, Update, and Delete (CRUD) methods.

So, the QuoteModuleService class now has methods like createQuotes and retrieveQuote.

NoteFind all methods generated by the MedusaService in this reference.

You'll use this service later when you implement custom flows for quote management.

Export Module Definition#

The final piece to a module is its definition, which you export in an index.ts file at its root directory. This definition tells Medusa the name of the module and its service.

So, create the file src/modules/quote/index.ts with the following content:

Directory structure after adding the module definition

src/modules/quote/index.ts
1import { Module } from "@medusajs/framework/utils"2import QuoteModuleService from "./service"3
4export const QUOTE_MODULE = "quote"5
6export default Module(QUOTE_MODULE, { 7  service: QuoteModuleService,8})

You use the Module function from the Modules SDK to create the module's definition. It accepts two parameters:

  1. The module's name, which is quote.
  2. An object with a required property service indicating the module's service.

You also export the module's name as QUOTE_MODULE so you can reference it later.

Add Module to Medusa's Configurations#

Once you finish building the module, add it to Medusa's configurations to start using it.

In medusa-config.ts, add a modules property and pass an array with your custom module:

medusa-config.ts
1module.exports = defineConfig({2  // ...3  modules: [4    {5      resolve: "./src/modules/quote",6    },7  ],8})

Each object in the modules array has a resolve property, whose value is either a path to the module's directory, or an npm package’s name.

Generate Migrations#

Since data models represent tables in the database, you define how they're created in the database with migrations. A migration is a TypeScript or JavaScript file that defines database changes made by a module.

NoteLearn more about migrations in this documentation.

Medusa's CLI tool generates the migrations for you. To generate a migration for the Quote Module, run the following command in your Medusa application's directory:

Terminal
npx medusa db:generate quote

The db:generate command of the Medusa CLI accepts the name of the module to generate the migration for. You'll now have a migrations directory under src/modules/quote that holds the generated migration.

The directory structure of the Quote Module after generating the migration

Then, to reflect these migrations on the database, run the following command:

Terminal
npx medusa db:migrate

The table for the Quote data model is now created in the database.


When you defined the Quote data model, you added properties that store the ID of records managed by other modules. For example, the customer_id property stores the ID of the customer that requested the quote, but customers are managed by the Customer Module.

Medusa integrates modules into your application without implications or side effects by isolating modules from one another. This means you can't directly create relationships between data models in your module and data models in other modules.

Instead, Medusa provides the mechanism to define links between data models, and retrieve and manage linked records while maintaining module isolation. Links are useful to define associations between data models in different modules, or extend a model in another module to associate custom properties with it.

NoteTo learn more about module isolation, refer to the Module Isolation documentation.

In this step, you'll define the following links between the Quote Module's data model and data models in other modules:

  1. Quote <> Cart data model of the Cart Module: link quotes to the carts they were created from.
  2. Quote <> Customer data model of the Customer Module: link quotes to the customers who requested them.
  3. Quote <> OrderChange data model of the Order Module: link quotes to the order changes that record adjustments made to the quote's draft order.
  4. Quote <> Order data model of the Order Module: link quotes to their draft orders that are later converted to orders.

You can define links between data models in a TypeScript or JavaScript file under the src/links directory. So, to define the link between the Quote and Cart data models, create the file src/links/quote-cart.ts with the following content:

Directory structure after adding the quote-cart link

src/links/quote-cart.ts
1import { defineLink } from "@medusajs/framework/utils"2import QuoteModule from "../modules/quote"3import CartModule from "@medusajs/medusa/cart"4
5export default defineLink(6  {7    ...QuoteModule.linkable.quote,8    field: "cart_id",9  },10  CartModule.linkable.cart,11  {12    readOnly: true,13  }14)

You define a link using the defineLink function from the Modules SDK. It accepts three parameters:

  1. An object indicating the first data model part of the link. A module has a special linkable property that contains link configurations for its data models. So, you can pass the link configurations for the Quote data model from the QuoteModule module, specifying that its cart_id property holds the ID of the linked record.
  2. An object indicating the second data model part of the link. You pass the link configurations for the Cart data model from the CartModule module.
  3. An optional object with additional configurations for the link. By default, Medusa creates a table in the database to represent the link you define. However, when you only want to retrieve the linked records without managing and storing the links, you can set the readOnly option to true.

You'll now be able to retrieve the cart that a quote was created from, as you'll see in later steps.

Next, you'll define the link between the Quote and Customer data model of the Customer Module. So, create the file src/links/quote-customer.ts with the following content:

Directory structure after adding the quote-customer link

src/links/quote-customer.ts
1import { defineLink } from "@medusajs/framework/utils"2import QuoteModule from "../modules/quote"3import CustomerModule from "@medusajs/medusa/customer"4
5export default defineLink(6  {7    ...QuoteModule.linkable.quote,8    field: "customer_id",9  },10  CustomerModule.linkable.customer,11  {12    readOnly: true,13  }14)

You define the link between the Quote and Customer data models in the same way as the Quote and Cart link. In the first object parameter of defineLink, you pass the linkable configurations of the Quote data model, specifying the customer_id property as the link field. In the second object parameter, you pass the linkable configurations of the Customer data model from the Customer Module. You also configure the link to be read-only.

Next, you'll define the link between the Quote and OrderChange data model of the Order Module. So, create the file src/links/quote-order-change.ts with the following content:

Directory structure after adding the quote-order-change link

src/links/quote-order-change.ts
1import { defineLink } from "@medusajs/framework/utils"2import QuoteModule from "../modules/quote"3import OrderModule from "@medusajs/medusa/order"4
5export default defineLink(6  {7    ...QuoteModule.linkable.quote,8    field: "order_change_id",9  },10  OrderModule.linkable.orderChange,11  {12    readOnly: true,13  }14)

You define the link between the Quote and OrderChange data models in the same way as the previous links. You pass the linkable configurations of the Quote data model, specifying the order_change_id property as the link field. In the second object parameter, you pass the linkable configurations of the OrderChange data model from the Order Module. You also configure the link to be read-only.

Finally, you'll define the link between the Quote and Order data model of the Order Module. So, create the file src/links/quote-order.ts with the following content:

Directory structure after adding the quote-order link

src/links/quote-order.ts
1import { defineLink } from "@medusajs/framework/utils"2import QuoteModule from "../modules/quote"3import OrderModule from "@medusajs/medusa/order"4
5export default defineLink(6  {7    ...QuoteModule.linkable.quote,8    field: "draft_order_id",9  },10  {11    ...OrderModule.linkable.order.id,12    alias: "draft_order",13  },14  {15    readOnly: true,16  }17)

You define the link between the Quote and Order data models similar to the previous links. You pass the linkable configurations of the Quote data model, specifying the draft_order_id property as the link field.

In the second object parameter, you pass the linkable configurations of the Order data model from the Order Module. You also set an alias property to draft_order. This allows you later to retrieve the draft order of a quote with the draft_order alias rather than the default order alias. Finally, you configure the link to be read-only.

You've finished creating the links that allow you to retrieve data related to quotes. You'll see how to use these links in later steps.


Step 4: Implement Create Quote Workflow#

You're now ready to start implementing quote-management features. The first one you'll implement is the ability for customers to request a quote for a set of items in their cart.

To build custom commerce features in Medusa, you create a workflow. A workflow is a series of queries and actions, called steps, that complete a task. You construct a workflow like you construct a function, but it's a special function that allows you to track its executions' progress, define roll-back logic, and configure other advanced features. Then, you execute the workflow from other customizations, such as in an endpoint.

So, in this section, you'll learn how to create a workflow that creates a quote for a customer.

NoteLearn more about workflows in the Workflows documentation.

The workflow will have the following steps:

The first four steps are provided by Medusa in its @medusajs/medusa/core-flows package. So, you only need to implement the createQuotesStep step.

createQuotesStep#

In the last step of the workflow, you'll create a quote for the customer using the Quote Module's service.

To create a step, create the file src/workflows/steps/create-quotes.ts with the following content:

Directory structure after adding the create-quotes step

src/workflows/steps/create-quotes.ts
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import { QUOTE_MODULE } from "../../modules/quote"3import QueryModuleService from "../../modules/quote/service"4
5type StepInput = {6  draft_order_id: string;7  order_change_id: string;8  cart_id: string;9  customer_id: string;10}[]11
12export const createQuotesStep = createStep(13  "create-quotes",14  async (input: StepInput, { container }) => {15    const quoteModuleService: QueryModuleService = container.resolve(16      QUOTE_MODULE17    )18
19    const quotes = await quoteModuleService.createQuotes(input)20
21    return new StepResponse(22      quotes,23      quotes.map((quote) => quote.id)24    )25  }26)

You create a step with createStep from the Workflows SDK. It accepts two parameters:

  1. The step's unique name, which is create-quotes.
  2. An async function that receives two parameters:
    • The step's input, which is in this case an array of quotes to create.
    • An object that has properties including the Medusa container, which is a registry of framework and commerce tools that you can access in the step.

In the step function, you resolve the Quote Module's service from the Medusa container using the resolve method of the container, passing it the module's name as a parameter.

Then, you create the quotes using the createQuotes method. As you remember, the Quote Module's service extends the MedusaService which generates data-management methods for you.

A step function must return a StepResponse instance. The StepResponse constructor accepts two parameters:

  1. The step's output, which is the quotes created.
  2. Data to pass to the step's compensation function, which you'll add next.

Add Compensation to Step

A step can have a compensation function that undoes the actions performed in a step. Then, if an error occurs during the workflow's execution, the compensation functions of executed steps are called to roll back the changes. This mechanism ensures data consistency in your application, especially as you integrate external systems.

To add a compensation function to a step, pass it as a third-parameter to createStep:

src/workflows/steps/create-quotes.ts
1export const createQuotesStep = createStep(2  // ...3  async (quoteIds, { container }) => {4    if (!quoteIds) {5      return6    }7    8    const quoteModuleService: QueryModuleService = container.resolve(9      QUOTE_MODULE10    )11
12    await quoteModuleService.deleteQuotes(quoteIds)13  }14)

The compensation function accepts two parameters:

  1. The data passed from the step in the second parameter of StepResponse, which in this case is an array of quote IDs.
  2. An object that has properties including the Medusa container.

In the compensation function, you resolve the Quote Module's service from the Medusa container and call the deleteQuotes method to delete the quotes created in the step.

createRequestForQuoteWorkflow#

You can now create the workflow using the steps provided by Medusa and your custom step.

To create the workflow, create the file src/workflows/create-request-for-quote.ts with the following content:

src/workflows/create-request-for-quote.ts
19};20
21export const createRequestForQuoteWorkflow = createWorkflow(22  "create-request-for-quote",23  (input: WorkflowInput) => {24    // @ts-ignore25    const { data: carts } = useQueryGraphStep({26      entity: "cart",27      fields: [28        "id",29        "sales_channel_id",30        "currency_code",31        "region_id",32        "customer.id",33        "customer.email",34        "shipping_address.*",35        "billing_address.*",36        "items.*",37        "shipping_methods.*",38        "promotions.code",39      ],40      filters: { id: input.cart_id },41      options: {42        throwIfKeyNotFound: true,43      },44    })45
46    const { data: customers } = useQueryGraphStep({47      entity: "customer",48      fields: ["id", "customer"],49      filters: { id: input.customer_id },50      options: {51        throwIfKeyNotFound: true,52      },53    }).config({ name: "customer-query" })54
55    // TODO create order56  }57)

You create a workflow using createWorkflow from the Workflows SDK. It accepts the workflow's unique name as a first parameter.

It accepts as a second parameter a constructor function, which is the workflow's implementation. The function can accept input, which in this case is an object having the ID of the customer requesting the quote, and the ID of their cart.

In the workflow's constructor function, you use useQueryGraphStep to retrieve the cart and customer details using the IDs passed as an input to the workflow.

TipuseQueryGraphStep uses Query, whic allows you to retrieve data across modules. For example, in the above snippet you're retrieving the cart's promotions, which are managed in the Promotion Module, by passing promotions.code to the fields array.

Next, you want to create the draft order for the quote. Replace the TODO in the workflow with the following:

src/workflows/create-request-for-quote.ts
1const orderInput = transform({ carts, customers }, ({ carts, customers }) => {2  return {3    is_draft_order: true,4    status: OrderStatus.DRAFT,5    sales_channel_id: carts[0].sales_channel_id || undefined,6    email: customers[0].email || undefined,7    customer_id: customers[0].id || undefined,8    billing_address: carts[0].billing_address,9    shipping_address: carts[0].shipping_address,10    items: carts[0].items as CreateOrderLineItemDTO[] || [],11    region_id: carts[0].region_id || undefined,12    promo_codes: carts[0].promotions?.map((promo) => promo?.code),13    currency_code: carts[0].currency_code,14    shipping_methods: carts[0].shipping_methods || [],15  } as CreateOrderWorkflowInput16})17
18const draftOrder = createOrderWorkflow.runAsStep({19  input: orderInput,20})21
22// TODO create order change

You first prepare the order's details using transform from the Workflows SDK. Since Medusa creates an internal representation of the workflow's constructor before any data actually has a value, you can't manipulate data directly in the function. So, Medusa provides utilities like transform to manipulate data instead. You can learn more in the transform variables documentation.

Then, you create the draft order using the createOrderWorkflow workflow which you imported from @medusajs/medusa/core-flows. The workflow creates and returns the created order.

After that, you want to create an order change for the draft order. This will allow the admin later to make edits to the draft order, such as updating the prices or quantities of the items in the order.

Replace the TODO with the following:

src/workflows/create-request-for-quote.ts
1const orderEditInput = transform({ draftOrder }, ({ draftOrder }) => {2  return {3    order_id: draftOrder.id,4    description: "",5    internal_note: "",6    metadata: {},7  }8})9
10const changeOrder = beginOrderEditOrderWorkflow.runAsStep({11  input: orderEditInput,12})13
14// TODO create quote

You prepare the order change's details using transform and then create the order change using the beginOrderEditOrderWorkflow workflow which is provided by Medusa.

Finally, you want to create the quote for the customer and return it. Replace the last TODO with the following:

src/workflows/create-request-for-quote.ts
1const quoteData = transform({2  draftOrder,3  carts,4  customers,5  changeOrder,6}, ({ draftOrder, carts, customers, changeOrder }) => {7  return {8    draft_order_id: draftOrder.id,9    cart_id: carts[0].id,10    customer_id: customers[0].id,11    order_change_id: changeOrder.id,12  }13})14
15const quotes = createQuotesStep([16  quoteData,17])18
19return new WorkflowResponse({ quote: quotes[0] })

Similar to before, you prepare the quote's details using transform. Then, you create the quote using the createQuotesStep you implemented earlier.

A workflow must return an instance of WorkflowResponse. The WorkflowResponse constructor accepts the workflow's output as a parameter, which is an object holding the created quote in this case.

In the next step, you'll learn how to execute the workflow when a customer requests a quote.


Step 5: Create Quote API Route#

Now that you have the logic to create a quote for a customer, you need to expose it so that frontend clients, such as a storefront, can use it. You do this by creating an API route.

An API Route is an endpoint that exposes commerce features to external applications and clients, such as storefronts. You'll create an API route at the path /store/customers/me/quotes that executes the workflow from the previous step.

NoteLearn more about API routes in this documentation.

Implement API Route#

An API route is created in a route.ts file under a sub-directory of the src/api directory. The path of the API route is the file's path relative to src/api.

By default, all routes starting with /store/customers/me require the customer to be authenticated. So, you'll be creating the API route at /store/customers/me/quotes.

To create the API route, create the file src/api/store/customers/me/quotes/route.ts with the following content:

Directory structure after adding the store/quotes route

src/api/store/customers/me/quotes/route.ts
8} from "../../../../../workflows/create-request-for-quote"9
10type CreateQuoteType = {11  cart_id: string;12}13
14export const POST = async (15  req: AuthenticatedMedusaRequest<CreateQuoteType>,16  res: MedusaResponse17) => {18  const {19    result: { quote: createdQuote },20  } = await createRequestForQuoteWorkflow(req.scope).run({21    input: {22      ...req.validatedBody,23      customer_id: req.auth_context.actor_id,24    },25  })26
27  const query = req.scope.resolve(28    ContainerRegistrationKeys.QUERY29  )30
31  const {32    data: [quote],33  } = await query.graph(34    {35      entity: "quote",36      fields: req.queryConfig.fields,37      filters: { id: createdQuote.id },38    },39    { throwIfKeyNotFound: true }40  )41
42  return res.json({ quote })43}

Since you export a POST function in this file, you're exposing a POST API route at /store/customers/me/quotes. The route handler function accepts two parameters:

  1. A request object with details and context on the request, such as body parameters or authenticated customer details.
  2. A response object to manipulate and send the response.
TipAuthenticatedMedusaRequest accepts the request body's type as a type argument.

In the route handler function, you create the quote using the createRequestForQuoteWorkflow from the previous step. Then, you resolve Query from the Medusa container, which is available in the request object's req.scope property.

You use Query to retrieve the Quote with its fields and linked records, which you'll learn how to specify soon. Finally, you send the quote as a response.

Add Validation Schema#

The API route accepts the cart ID as a request body parameter. So, it's important to validate the body of a request before executing the route's handler. You can do this by specifying a validation schema in a middleware for the API route.

In Medusa, you create validation schemas using Zod in a TypeScript file under the src/api directory. So, create the file src/api/store/validators.ts with the following content:

Directory structure after adding the validators file

src/api/store/validators.ts
1import { z } from "zod"2
3export type CreateQuoteType = z.infer<typeof CreateQuote>;4export const CreateQuote = z5  .object({6    cart_id: z.string().min(1),7  })8  .strict()

You define a CreateQuote schema using Zod that specifies the cart_id parameter as a required string.

You also export a type inferred from the schema. So, go back to src/api/store/customers/me/quotes/route.ts and replace the implementation of CreateQuoteType to import the type from the validators.ts file instead:

src/api/store/customers/me/quotes/route.ts
1// other imports...2// add the following import3import { CreateQuoteType } from "../../../validators"4
5// remove CreateQuoteType definition6
7export const POST = async (8  // keep type argument the same9  req: AuthenticatedMedusaRequest<CreateQuoteType>,10  res: MedusaResponse11) => {12  // ...13}

Apply Validation Schema Middleware#

Now that you have the validation schema, you need to add the middleware that ensures the request body is validated before the route handler is executed. A middleware is a function executed when a request is sent to an API Route. It's executed before the route handler.

NoteLearn more about middleware in the Middlewares documentation.

Middlewares are created in the src/api/middlewares.ts file. So create the file src/api/middlewares.ts with the following content:

Directory structure after adding the store middlewares file

src/api/middlewares.ts
1import { 2  defineMiddlewares, 3  validateAndTransformBody,4} from "@medusajs/framework/http"5import { CreateQuote } from "./store/validators"6
7export default defineMiddlewares({8  routes: [9    {10      method: ["POST"],11      matcher: "/store/customers/me/quotes",12      middlewares: [13        validateAndTransformBody(CreateQuote),14      ],15    },16  ],17})

To export the middlewares, you use the defineMiddlewares function. It accepts an object having a routes property, whose value is an array of middleware route objects. Each middleware route object has the following properties:

  • method: The HTTP methods the middleware applies to, which is in this case POST.
  • matcher: The path of the route the middleware applies to.
  • middlewares: An array of middleware functions to apply to the route. In this case, you apply the validateAndTransformBody middleware, which accepts a Zod schema as a parameter and validates that a request's body matches the schema. If not, it throws and returns an error.

Specify Quote Fields to Retrieve#

In the route handler you just created, you specified what fields to retrieve in a quote using the req.queryConfig.fields property. The req.queryConfig field holds query configurations indicating the default fields to retrieve when using Query to return data in a request. This is useful to unify the returned data structure across different routes, or to allow clients to specify the fields they want to retrieve.

To add the Query configurations, you'll first create a file that exports the default fields to retrieve for a quote, then apply them in a validateAndTransformQuery middleware.

NoteLearn more about configuring Query for requests in the Request Query Configurations documentation.

Create the file src/api/store/customers/me/quotes/query-config.ts with the following content:

Directory structure after adding the query-config file

src/api/store/customers/me/quotes/query-config.ts
1export const quoteFields = [2  "id",3  "status",4  "*customer",5  "cart.id",6  "draft_order.id",7  "draft_order.currency_code",8  "draft_order.display_id",9  "draft_order.region_id",10  "draft_order.status",11  "draft_order.version",12  "draft_order.summary",13  "draft_order.total",14  "draft_order.subtotal",15  "draft_order.tax_total",16  "draft_order.order_change",17  "draft_order.discount_total",18  "draft_order.discount_tax_total",19  "draft_order.original_total",20  "draft_order.original_tax_total",21  "draft_order.item_total",22  "draft_order.item_subtotal",23  "draft_order.item_tax_total",24  "draft_order.original_item_total",25  "draft_order.original_item_subtotal",26  "draft_order.original_item_tax_total",27  "draft_order.shipping_total",28  "draft_order.shipping_subtotal",29  "draft_order.shipping_tax_total",30  "draft_order.original_shipping_tax_total",31  "draft_order.original_shipping_subtotal",32  "draft_order.original_shipping_total",33  "draft_order.created_at",34  "draft_order.updated_at",35  "*draft_order.items",36  "*draft_order.items.tax_lines",37  "*draft_order.items.adjustments",38  "*draft_order.items.variant",39  "*draft_order.items.variant.product",40  "*draft_order.items.detail",41  "*draft_order.payment_collections",42  "*order_change.actions",43]44
45export const retrieveStoreQuoteQueryConfig = {46  defaults: quoteFields,47  isList: false,48}49
50export const listStoreQuoteQueryConfig = {51  defaults: quoteFields,52  isList: true,53}

You export two objects:

  • retrieveStoreQuoteQueryConfig: Specifies the default fields to retrieve for a single quote.
  • listStoreQuoteQueryConfig: Specifies the default fields to retrieve for a list of quotes, which you'll use later.

Notice that in the fields retrieved, you specify linked records such as customer and draft_order. You can do this because you've defined links between the Quote data model and these data models previously.

NoteFor simplicity, this guide will apply the listStoreQuoteQueryConfig to all routes starting with /store/customers/me/quotes. However, you should instead apply retrieveStoreQuoteQueryConfig to routes that retrieve a single quote, and listStoreQuoteQueryConfig to routes that retrieve a list of quotes.

Next, you'll define a Zod schema that allows client applications to specify the fields they want to retrieve in a quote as a query parameter. In src/api/store/validators.ts, add the following schema:

src/api/store/validators.ts
1// other imports...2import { createFindParams } from "@medusajs/medusa/api/utils/validators"3
4// ...5
6export type GetQuoteParamsType = z.infer<typeof GetQuoteParams>;7export const GetQuoteParams = createFindParams({8  limit: 15,9  offset: 0,10})

You create a GetQuoteParams schema using the createFindParams utility from Medusa. This utility creates a schema that allows clients to specify query parameters such as:

  • fields: The fields to retrieve in a quote.
  • limit: The maximum number of quotes to retrieve. This is useful for routes that return a list of quotes.
  • offset: The number of quotes to skip before retrieving the next set of quotes. This is useful for routes that return a list of quotes.
  • order: The fields to sort the quotes by either in ascending or descending order. This is useful for routes that return a list of quotes.

Finally, you'll apply these Query configurations in a middleware. So, add the following middleware in src/api/middlewares.ts:

src/api/store/middlewares.ts
1// other imports...2import { GetQuoteParams } from "./store/validators"3import { validateAndTransformQuery } from "@medusajs/framework/http"4import { listStoreQuoteQueryConfig } from "./store/customers/me/quotes/query-config"5
6export default defineMiddlewares({7  routes: [8    // ...9    {10      matcher: "/store/customers/me/quotes*",11      middlewares: [12        validateAndTransformQuery(13          GetQuoteParams, 14          listStoreQuoteQueryConfig15        ),16      ],17    },18  ],19})

You apply the validateAndTransformQuery middleware on all routes starting with /store/customers/me/quotes. The validateAndTransformQuery middleware that Medusa provides accepts two parameters:

  1. A Zod schema that specifies how to validate the query parameters of incoming requests.
  2. A Query configuration object that specifies the default fields to retrieve in the response, which you defined in the query-config.ts file.

The create quote route is now ready to be used by clients to create quotes for customers.

Test the API Route#

To test out the API route, start the Medusa application:

Then, open the Medusa Admin dashboard at http://localhost:9000/app and login using the credentials you set up earlier.

Retrieve Publishable API Key

All requests sent to routes starting with /store must have a publishable API key in their header. This ensures that the request is scoped to a specific sales channel of your storefront.

NoteTo learn more about publishable API keys, refer to the Publishable API Key documentation.

To retrieve the publishable API key from the Medusa Admin, refer to this user guide.

Retrieve Customer Authentication Token

As mentioned before, the API route you added requires the customer to be authenticated. So, you'll first create a customer, then retrieve their authentication token to use in the request.

Before creating the customer, retrieve a registration token using the Retrieve Registration JWT Token API route:

Code
1curl -X POST 'http://localhost:9000/auth/customer/emailpass/register' \2-H 'Content-Type: application/json' \3--data-raw '{4  "email": "customer@gmail.com",5  "password": "supersecret"6}'

Make sure to replace the email and password with the credentials you want.

Then, register the customer using the Create Customer API route:

Code
1curl -X POST 'http://localhost:9000/store/customers' \2-H 'Authorization: Bearer {token}' \3-H 'Content-Type: application/json' \4-H 'x-publishable-api-key: {your_publishable_api_key}' \5--data-raw '{6  "email": "customer@gmail.com"7}'

Make sure to replace:

  • {token} with the registration token you received from the previous request.
  • {your_publishable_api_key} with the publishable API key you retrieved from the Medusa Admin.

Also, if you changed the email in the first request, make sure to change it here as well.

The customer is now registered. Lastly, you need to retrieve its authenticated token by sending a request to the Authenticate Customer API route:

Code
1curl -X POST 'http://localhost:9000/auth/customer/emailpass' \2-H 'Content-Type: application/json' \3--data-raw '{4  "email": "customer@gmail.com",5  "password": "supersecret"6}'

Copy the returned token to use it in the next requests.

Create Cart

The customer needs a cart with an item before creating the quote.

A cart requires a region ID. You can retrieve a region ID using the List Regions API route:

Code
1curl 'http://localhost:9000/store/regions' \2-H 'x-publishable-api-key: {your_publishable_api_key}'

Make sure to replace the {your_publishable_api_key} with the publishable API key you retrieved from the Medusa Admin.

Then, create a cart for the customer using the Create Cart API route:

Code
1curl -X POST 'http://localhost:9000/store/carts' \2-H 'Authorization: Bearer {token}' \3-H 'Content-Type: application/json' \4-H 'x-publishable-api-key: {your_publishable_api_key}' \5--data '{6    "region_id": "{region_id}"7}'

Make sure to replace:

  • {token} with the authentication token you received from the previous request.
  • {your_publishable_api_key} with the publishable API key you retrieved from the Medusa Admin.
  • {region_id} with the region ID you retrieved from the previous request.

This will create and return a cart. Copy its ID for the next request.

You now need to add a product variant to the cart. You can retrieve a product variant ID using the List Products API route:

Code
1curl 'http://localhost:9000/store/products' \2-H 'x-publishable-api-key: {your_publishable_api_key}'

Make sure to replace the {your_publishable_api_key} with the publishable API key you retrieved from the Medusa Admin.

Copy the ID of a variant in a product from the response.

Finally, to add the product variant to the cart, use the Add Item to Cart API route:

Code
1curl -X POST 'http://localhost:9000/store/carts/{id}/line-items' \2-H 'Authorization: Bearer {token}' \3-H 'Content-Type: application/json' \4-H 'x-publishable-api-key: {your_publishable_api_key}' \5--data-raw '{6  "variant_id": "{variant_id}",7  "quantity": 1,8}'

Make sure to replace:

  • {id} with the cart ID you retrieved previously.
  • {token} with the authentication token you retrieved previously.
  • {your_publishable_api_key} with the publishable API key you retrieved from the Medusa Admin.
  • {variant_id} with the product variant ID you retrieved in the previous request.

This adds the product variant to the cart. You can now use the cart to create a quote.

Note

For more accurate totals and processing of the quote's draft order, you should:

You can also learn how to build a checkout experience in a storefront by following this storefront development guide. It's not specific to quote management, so you'll need to change the last step to create a quote instead of an order.

Create Quote

To create a quote for the customer, send a request to the /store/customers/me/quotes route you created:

Code
1curl -X POST 'http://localhost:9000/store/customers/me/quotes' \2-H 'Authorization: Bearer {token}' \3-H 'Content-Type: application/json' \4-H 'x-publishable-api-key: {your_publishable_api_key}' \5--data-raw '{6  "cart_id": "{cart_id}"7}'

Make sure to replace:

  • {token} with the authentication token you retrieved previously.
  • {your_publishable_api_key} with the publishable API key you retrieved from the Medusa Admin.
  • {cart_id} with the ID of the customer's cart.

This will create a quote for the customer and you'll receive its details in the response.


Step 6: List Quotes API Route#

After the customer creates a quote, the admin user needs to view these quotes to manage them. In this step, you'll create the API route to list quotes for the admin user. Then, in the next step, you'll customize the Medusa Admin dashboard to display these quotes.

The process of creating this API route will be somewhat similar to the previous route you created. You'll create the route, define the query configurations, and apply them in a middleware.

Implement API Route#

To create the API route, create the file src/api/admin/quotes/route.ts with the following content:

Directory structure after adding the admin quotes route

src/api/admin/quotes/route.ts
1import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"2import { ContainerRegistrationKeys } from "@medusajs/framework/utils"3
4export const GET = async (5  req: MedusaRequest,6  res: MedusaResponse7) => {8  const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)9
10  const { data: quotes, metadata } = await query.graph({11    entity: "quote",12    ...req.queryConfig,13  })14
15  res.json({16    quotes,17    count: metadata!.count,18    offset: metadata!.skip,19    limit: metadata!.take,20  })21}

You export a GET function in this file, which exposes a GET API route at /admin/quotes.

In the route handler function, you resolve Query from the Medusa container and use it to retrieve the list of quotes. Similar to before, you use req.queryConfig to specify the fields to retrieve in the response.

req.queryConfig also includes pagination parameters, such as limit, offset, and count, and they're returned in the metadata property of Query's result. You return the pagination details and the list of quotes in the response.

NoteLearn more about paginating Query results in the Query documentation.

Add Query Configurations#

Similar to before, you need to specify the default fields to retrieve in a quote and apply them in a middleware for this new route.

Since this is an admin route, create the file src/api/admin/quotes/query-config.ts with the following content:

Directory structure after adding the query-config file

src/api/admin/quotes/query-config.ts
1export const quoteFields = [2  "id",3  "status",4  "created_at",5  "updated_at",6  "*customer",7  "cart.id",8  "draft_order.id",9  "draft_order.currency_code",10  "draft_order.display_id",11  "draft_order.region_id",12  "draft_order.status",13  "draft_order.version",14  "draft_order.summary",15  "draft_order.total",16  "draft_order.subtotal",17  "draft_order.tax_total",18  "draft_order.order_change",19  "draft_order.discount_total",20  "draft_order.discount_tax_total",21  "draft_order.original_total",22  "draft_order.original_tax_total",23  "draft_order.item_total",24  "draft_order.item_subtotal",25  "draft_order.item_tax_total",26  "draft_order.original_item_total",27  "draft_order.original_item_subtotal",28  "draft_order.original_item_tax_total",29  "draft_order.shipping_total",30  "draft_order.shipping_subtotal",31  "draft_order.shipping_tax_total",32  "draft_order.original_shipping_tax_total",33  "draft_order.original_shipping_subtotal",34  "draft_order.original_shipping_total",35  "draft_order.created_at",36  "draft_order.updated_at",37  "*draft_order.items",38  "*draft_order.items.tax_lines",39  "*draft_order.items.adjustments",40  "*draft_order.items.variant",41  "*draft_order.items.variant.product",42  "*draft_order.items.detail",43  "*order_change.actions",44]45
46export const retrieveAdminQuoteQueryConfig = {47  defaults: quoteFields,48  isList: false,49}50
51export const listAdminQuoteQueryConfig = {52  defaults: quoteFields,53  isList: true,54}

You export two objects: retrieveAdminQuoteQueryConfig and listAdminQuoteQueryConfig, which specify the default fields to retrieve for a single quote and a list of quotes, respectively.

NoteFor simplicity, this guide will apply the listAdminQuoteQueryConfig to all routes starting with /admin/quotes. However, you should instead apply retrieveAdminQuoteQueryConfig to routes that retrieve a single quote, and listAdminQuoteQueryConfig to routes that retrieve a list of quotes.

Next, you'll define a Zod schema that allows client applications to specify the fields to retrieve and pagination fields as a query parameter. Create the file src/api/admin/validators.ts with the following content:

Directory structure after adding the admin validators file

src/api/admin/validators.ts
1import {2  createFindParams,3} from "@medusajs/medusa/api/utils/validators"4
5export const AdminGetQuoteParams = createFindParams({6  limit: 15,7  offset: 0,8})9  .strict()

You define the AdminGetQuoteParams schema using the createFindParams utility from Medusa. The schema allows clients to specify query parameters such as:

  • fields: The fields to retrieve in a quote.
  • limit: The maximum number of quotes to retrieve.
  • offset: The number of quotes to skip before retrieving the next set of quotes.
  • order: The fields to sort the quotes by either in ascending or descending order.

Finally, you need to apply the validateAndTransformQuery middleware on this route. So, add the following to src/api/middlewares.ts:

src/api/middlewares.ts
1// other imports...2import { AdminGetQuoteParams } from "./admin/quotes/validators"3import { listAdminQuoteQueryConfig } from "./admin/quotes/query-config"4
5export default defineMiddlewares({6  routes: [7    // ...8    {9      matcher: "/admin/quotes*",10      middlewares: [11        validateAndTransformQuery(12          AdminGetQuoteParams,13          listAdminQuoteQueryConfig14        ),15      ],16    },17  ],18})

You add the validateAndTransformQuery middleware to all routes starting with /admin/quotes. It validates the query parameters and sets the Query configurations based on the defaults you defined and the passed query parameters.

Your API route is now ready for use. You'll test it in the next step by customizing the Medusa Admin dashboard to display the quotes.


Step 7: List Quotes Route in Medusa Admin#

Now that you have the API route to retrieve the list of quotes, you want to show these quotes to the admin user in the Medusa Admin dashboard. The Medusa Admin is customizable, allowing you to add new pages as UI routes.

A UI route is a React component that specifies the content to be shown in a new page in the Medusa Admin dashboard. You'll create a UI route to display the list of quotes in the Medusa Admin.

NoteLearn more about UI routes in the UI Routes documentation.

Configure JS SDK#

Medusa provides a JS SDK that you can use to send requests to the Medusa server from any client application, including your Medusa Admin customizations.

The JS SDK is installed by default in your Medusa application. To configure it, create the file src/admin/lib/sdk.ts with the following content:

Directory structure after adding the sdk file

src/admin/lib/sdk.ts
1import Medusa from "@medusajs/js-sdk"2
3export const sdk = new Medusa({4  baseUrl: import.meta.env.VITE_BACKEND_URL || "/",5  debug: import.meta.env.DEV,6  auth: {7    type: "session",8  },9})

You create an instance of the JS SDK using the Medusa class from the @medusajs/js-sdk package. You pass it an object having the following properties:

  • baseUrl: The base URL of the Medusa server.
  • debug: A boolean indicating whether to log debug information.
  • auth: An object specifying the authentication type. When using the JS SDK for admin customizations, you use the session authentication type.

Add Admin Types#

In your development, you'll need types that represents the data you'll retrieve from the Medusa server. So, create the file src/admin/types.ts with the following content:

Directory structure after adding the admin type

src/admin/types.ts
1import {2  AdminCustomer,3  AdminOrder,4  AdminUser,5  FindParams,6  PaginatedResponse,7  StoreCart,8} from "@medusajs/framework/types"9
10export type AdminQuote = {11  id: string;12  status: string;13  draft_order_id: string;14  order_change_id: string;15  cart_id: string;16  customer_id: string;17  created_at: string;18  updated_at: string;19  draft_order: AdminOrder;20  cart: StoreCart;21  customer: AdminCustomer22};23
24export interface QuoteQueryParams extends FindParams {}25
26export type AdminQuotesResponse = PaginatedResponse<{27  quotes: AdminQuote[];28}>29
30export type AdminQuoteResponse = {31  quote: AdminQuote;32};

You define the following types:

  • AdminQuote: Represents a quote.
  • QuoteQueryParams: Represents the query parameters that can be passed when retrieving qoutes.
  • AdminQuotesResponse: Represents the response when retrieving a list of quotes.
  • AdminQuoteResponse: Represents the response when retrieving a single quote, which you'll implement later in this guide.

You'll use these types in the rest of the customizations.

Create useQuotes Hook#

When sending requests to the Medusa server from your admin customizations, it's recommended to use Tanstack Query, allowing you to benefit from its caching and data fetching capabilities.

So, you'll create a useQuotes hook that uses Tanstack Query and the JS SDK to fetch the list of quotes from the Medusa server.

Create the file src/admin/hooks/quotes.tsx with the following content:

Directory structure after adding the hooks quotes file

src/admin/hooks/quotes.tsx
1import { ClientHeaders, FetchError } from "@medusajs/js-sdk"2import {3  QuoteQueryParams,4  AdminQuotesResponse,5} from "../types"6import {7  QueryKey,8  useQuery,9  UseQueryOptions,10} from "@tanstack/react-query"11import { sdk } from "../lib/sdk"12
13export const useQuotes = (14  query: QuoteQueryParams,15  options?: UseQueryOptions<16    AdminQuotesResponse,17    FetchError,18    AdminQuotesResponse,19    QueryKey20  >21) => {22  const fetchQuotes = (query: QuoteQueryParams, headers?: ClientHeaders) =>23    sdk.client.fetch<AdminQuotesResponse>(`/admin/quotes`, {24      query,25      headers,26    })27
28  const { data, ...rest } = useQuery({29    ...options,30    queryFn: () => fetchQuotes(query)!,31    queryKey: ["quote", "list"],32  })33
34  return { ...data, ...rest }35}

You define a useQuotes hook that accepts query parameters and optional options as a parameter. In the hook, you use the JS SDK's client.fetch method to retrieve the quotes from the /admin/quotes route.

You return the fetched data from the Medusa server. You'll use this hook in the UI route.

Create Quotes UI Route#

You can now create the UI route that will show a new page in the Medusa Admin with the list of quotes.

UI routes are created in a page.tsx file under the src/admin/routes directory. The path of the UI route is the file's path relative to src/admin/routes.

So, to add the UI route at /quotes in the Medusa Admin, create the file src/admin/routes/quotes/page.tsx with the following content:

Directory structure after adding the Quotes UI route file

src/admin/routes/quotes/page.tsx
1import { defineRouteConfig } from "@medusajs/admin-sdk"2import { DocumentText } from "@medusajs/icons"3import { 4  Container, createDataTableColumnHelper, DataTable, 5  DataTablePaginationState, Heading, Toaster, useDataTable,6} from "@medusajs/ui"7import { useNavigate } from "react-router-dom"8import { useQuotes } from "../../hooks/quotes"9import { AdminQuote } from "../../types"10import { useState } from "react"11
12const Quotes = () => {13  // TODO implement page content14}15
16export const config = defineRouteConfig({17  label: "Quotes",18  icon: DocumentText,19})20
21export default Quotes

The route file must export a React component that implements the content of the page. To show a link to the route in the sidebar, you can also export a configuation object created with defineRouteConfig that specifies the label and icon of the route in the Medusa Admin sidebar.

In the Quotes component, you'll show a table of quotes using the DataTable component from Medusa UI. This componet requires you first define the columns of the table.

To define the table's columns, add in the same file and before the Quotes component the following:

src/admin/routes/quotes/page.tsx
1const StatusTitles: Record<string, string> = {2  accepted: "Accepted",3  customer_rejected: "Customer Rejected",4  merchant_rejected: "Merchant Rejected",5  pending_merchant: "Pending Merchant",6  pending_customer: "Pending Customer",7}8
9const columnHelper = createDataTableColumnHelper<AdminQuote>()10
11const columns = [12  columnHelper.accessor("draft_order.display_id", {13    header: "ID",14  }),15  columnHelper.accessor("status", {16    header: "Status",17    cell: ({ getValue }) => StatusTitles[getValue()],18  }),19  columnHelper.accessor("customer.email", {20    header: "Email",21  }),22  columnHelper.accessor("draft_order.customer.first_name", {23    header: "First Name",24  }),25  columnHelper.accessor("draft_order.customer.company_name", {26    header: "Company Name",27  }),28  columnHelper.accessor("draft_order.total", {29    header: "Total",30    cell: ({ getValue, row }) => 31      `${row.original.draft_order.currency_code.toUpperCase()} ${getValue()}`,32  }),33  columnHelper.accessor("created_at", {34    header: "Created At",35    cell: ({ getValue }) => new Date(getValue()).toLocaleDateString(),36  }),37]

You use the createDataTableColumnHelper utility to create a function that allows you to define the columns of the table. Then, you create a columns array variable that defines the following columns:

  1. ID: The display ID of the quote's draft order.
  2. Status: The status of the quote. Here, you use an object to map the status to a human-readable title.
    • The cell property of the second object passed to the columnHelper.accessor function allows you to customize how the cell is rendered.
  3. Email: The email of the customer.
  4. First Name: The first name of the customer.
  5. Company Name: The company name of the customer.
  6. Total: The total amount of the quote's draft order. You format it to include the currency code.
  7. Created At: The date the quote was created.

Next, you'll use these columns to render the DataTable component in the Quotes component.

Change the implementation of Quotes to the following:

src/admin/routes/quotes/page.tsx
1const Quotes = () => {2  const navigate = useNavigate()3  const [pagination, setPagination] = useState<DataTablePaginationState>({4    pageSize: 15,5    pageIndex: 0,6  })7
8  const {9    quotes = [],10    count,11    isPending,12  } = useQuotes({13    limit: pagination.pageSize,14    offset: pagination.pageIndex * pagination.pageSize,15    fields:16      "+draft_order.total,*draft_order.customer",17    order: "-created_at",18  })19
20  const table = useDataTable({21    columns,22    data: quotes,23    getRowId: (quote) => quote.id,24    rowCount: count,25    isLoading: isPending,26    pagination: {27      state: pagination,28      onPaginationChange: setPagination,29    },30    onRowClick(event, row) {31      navigate(`/quotes/${row.id}`)32    },33  })34
35
36  return (37    <>38      <Container className="flex flex-col p-0 overflow-hidden">39        <Heading className="p-6 pb-0 font-sans font-medium h1-core">40          Quotes41        </Heading>42
43        <DataTable instance={table}>44          <DataTable.Toolbar>45            <Heading>Products</Heading>46          </DataTable.Toolbar>47          <DataTable.Table />48          <DataTable.Pagination />49        </DataTable>50      </Container>51      <Toaster />52    </>53  )54}

In the component, you use the useQuotes hook to fetch the quotes from the Medusa server. You pass the following query parameters in the request:

  • limit and offset: Pagination fields to specify the current page and the number of quotes to retrieve. These are based on the pagination state variable, which will be managed by the DataTable component.
  • fields: The fields to retrieve in the response. You specify the total amount of the draft order and the customer of the draft order. Since you prefix the fields with + and *, the fields are retrieved along with the default fields specified in the Query configurations.
  • order: The order in which to retrieve the quotes. Here, you retrieve the quotes in descending order of their creation date.

Next, you use the useDataTable hook to create a table instance with the columns you defined. You pass the fetched quotes to the DataTable component, along with configurations related to pagination and loading.

Notice that as part of the useDataTable configurations you naviagte to the /quotes/:id UI route when a row is clicked. You'll create that route in a later step.

Finally, you render the DataTable component to display the quotes in a table.

Test List Quotes UI Route#

You can now test out the UI route and the route added in the previous section from the Medusa Admin.

First, start the Medusa application:

Then, open the Medusa Admin dashboard at http://localhost:9000/app and login using the credentials you set up earlier.

You'll find a "Quotes" sidebar item. If you click on it, it will show you the table of quotes.

Quotes table in Medusa Admin


Step 8: Retrieve Quote API Route#

Next, you'll add an admin API route to retrieve a single quote. You'll use this route in the next step to add a UI route to view a quote's details. You'll later expand on that UI route to allow the admin to manage the quote.

To add the API route, create the file src/api/admin/quotes/[id]/route.ts with the following content:

Directory structure after adding the single quote route file

src/api/admin/quotes/[id]/route.ts
1import type {2  AuthenticatedMedusaRequest,3  MedusaResponse,4} from "@medusajs/framework/http"5import { ContainerRegistrationKeys } from "@medusajs/framework/utils"6
7export const GET = async (8  req: AuthenticatedMedusaRequest,9  res: MedusaResponse10) => {11  const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)12  const { id } = req.params13
14  const {15    data: [quote],16  } = await query.graph(17    {18      entity: "quote",19      filters: { id },20      fields: req.queryConfig.fields,21    },22    { throwIfKeyNotFound: true }23  )24
25  res.json({ quote })26}

You export a GET route handler, which will create a GET API route at /admin/quotes/:id.

In the route handler, you resolve Query and use it to retrieve the quote. You pass the ID in the path parameter as a filter in Query. You also pass the query configuration fields, which are the same as the ones you've configured before, to retrieve the default fields and the fields specified in the query parameter.

TipSince you applied the middleware earlier to the /admin/quotes* route pattern, it will automatically apply to this route as well.

You'll test this route in the next step as you create the UI route for a single quote.


Step 9: Quote Details UI Route#

In the Quotes List UI route, you configured the data table to navigate to a quote's page when you click on it in the table. Now that you have the API route to retrieve a single quote, you'll create the UI route that shows a quote's details.

Preview of the quote details page in Medusa Admin

Before you create the UI route, you need to create the hooks necessary to retrieve data from the Medusa server, and some components that will show the different elements of the page.

Add Hooks#

The first hook you'll add is a hook that will retrieve a quote using the API route you added in the previous step.

In src/api/admin/hooks/quote.tsx, add the following:

src/api/admin/hooks/quote.tsx
1// other imports...2import { AdminQuoteResponse } from "../types"3
4// ...5
6export const useQuote = (7  id: string,8  query?: QuoteQueryParams,9  options?: UseQueryOptions<10    AdminQuoteResponse,11    FetchError,12    AdminQuoteResponse,13    QueryKey14  >15) => {16  const fetchQuote = (17    id: string,18    query?: QuoteQueryParams,19    headers?: ClientHeaders20  ) =>21    sdk.client.fetch<AdminQuoteResponse>(`/admin/quotes/${id}`, {22      query,23      headers,24    })25
26  const { data, ...rest } = useQuery({27    queryFn: () => fetchQuote(id, query),28    queryKey: ["quote", id],29    ...options,30  })31
32  return { ...data, ...rest }33}

You define a useQuote hook that accepts the quote's ID and optional query parameters and options as parameters. In the hook, you use the JS SDK's client.fetch method to retrieve the quotes from the /admin/quotes/:id route.

The hook returns the fetched data from the Medusa server. You'll use this hook later in the UI route.

In addition, you'll need a hook to retrieve a preview of the quote's draft order. An order preview includes changes or edits to be applied on an order's items, such as changes in prices and quantities. Medusa already provides a Get Order Preview API route that you can use to retrieve the preview.

To create the hook, create the file src/admin/hooks/order-preview.tsx with the following content:

Directory structure after adding the order preview hook file

src/admin/hooks/order-preview.tsx
1import { HttpTypes } from "@medusajs/framework/types"2import { FetchError } from "@medusajs/js-sdk"3import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query"4import { sdk } from "../lib/sdk"5
6export const orderPreviewQueryKey = "custom_orders"7
8export const useOrderPreview = (9  id: string,10  query?: HttpTypes.AdminOrderFilters,11  options?: Omit<12    UseQueryOptions<13      HttpTypes.AdminOrderPreviewResponse,14      FetchError,15      HttpTypes.AdminOrderPreviewResponse,16      QueryKey17    >,18    "queryFn" | "queryKey"19  >20) => {21  const { data, ...rest } = useQuery({22    queryFn: async () => sdk.admin.order.retrievePreview(id, query),23    queryKey: [orderPreviewQueryKey, id],24    ...options,25  })26
27  return { ...data, ...rest }28}

You add a useOrderPreview hook that accepts as parameters the order's ID, query parameters, and options. In the hook, you use the JS SDK's admin.order.retrievePreview method to retrieve the order preview and return it.

You'll use this hook later in the quote's details page.

Add formatAmount Utility#

In the quote's details page, you'll display the amounts of the items in the quote. To format the amounts, you'll create a utility function that formats the amount based on the currency code.

Create the file src/admin/utils/format-amount.ts with the following content:

Directory structure after adding the format amount utility file

src/admin/utils/format-amount.ts
1export const formatAmount = (amount: number, currency_code: string) => {2  return new Intl.NumberFormat("en-US", {3    style: "currency",4    currency: currency_code,5  }).format(amount)6}

You define a formatAmount function that accepts an amount and a currency code as parameters. The function uses the Intl.NumberFormat API to format the amount as a currency based on the currency code.

You'll use this function in the UI route and its components.

Create Amount Component#

In the quote's details page, you want to display changes in amounts for items and totals. This is useful as you later add the capability to edit the price and quantity of items.

Diagram showcasing where this component will be in the page

To display changes in an amount, you'll create an Amount component and re-use it where necessary. So, create the file src/admin/components/amount.tsx with the following content:

Directory structure after adding the amount component

src/admin/components/amount.tsx
1import { clx } from "@medusajs/ui"2import { formatAmount } from "../utils/format-amount"3
4type AmountProps = {5  currencyCode: string;6  amount?: number | null;7  originalAmount?: number | null;8  align?: "left" | "right";9  className?: string;10};11
12export const Amount = ({13  currencyCode,14  amount,15  originalAmount,16  align = "left",17  className,18}: AmountProps) => {19  if (typeof amount === "undefined" || amount === null) {20    return (21      <div className="flex h-full w-full items-center">22        <span className="text-ui-fg-muted">-</span>23      </div>24    )25  }26
27  const formatted = formatAmount(amount, currencyCode)28  const originalAmountPresent = typeof originalAmount === "number"29  const originalAmountDiffers = originalAmount !== amount30  const shouldShowAmountDiff = originalAmountPresent && originalAmountDiffers31
32  return (33    <div34      className={clx(35        "flex h-full w-full items-center overflow-hidden",36        {37          "flex-col": shouldShowAmountDiff,38          "justify-start text-left": align === "left",39          "justify-end text-right": align === "right",40        },41        className42      )}43    >44      {shouldShowAmountDiff ? (45        <>46          <span className="truncate line-through text-xs">47            {formatAmount(originalAmount!, currencyCode)}48          </span>49          <span className="truncate text-blue-400 txt-small">{formatted}</span>50        </>51      ) : (52        <>53          <span className="truncate">{formatted}</span>54        </>55      )}56    </div>57  )58}

In this component, you show the current amount of an item and, if it has been changed, you show previous amount as well.

You'll use this component in other components whenever you want to display any amount that can be changed.

Create QuoteItems Component#

In the quote's UI route, you want to display the details of the items in the quote. You'll create a separate component that you'll use within the UI route.

Screenshot showcasing where this component will be in the page

Create the file src/admin/components/quote-items.tsx with the following content:

Directory structure after adding the quote items component

src/admin/components/quote-items.tsx
1import {2  AdminOrder,3  AdminOrderLineItem,4  AdminOrderPreview,5} from "@medusajs/framework/types"6import { Badge, Text } from "@medusajs/ui"7import { useMemo } from "react"8import { Amount } from "./amount-cell"9
10export const QuoteItem = ({11  item,12  originalItem,13  currencyCode,14}: {15  item: AdminOrderPreview["items"][0];16  originalItem?: AdminOrderLineItem;17  currencyCode: string;18}) => {19
20  const isItemUpdated = useMemo(21    () => !!item.actions?.find((a) => a.action === "ITEM_UPDATE"),22    [item]23  )24
25  return (26    <div27      key={item.id}28      className="text-ui-fg-subtle grid grid-cols-2 items-center gap-x-4 px-6 py-4 text-left"29    >30      <div className="flex items-start gap-x-4">31        <div>32          <Text33            size="small"34            leading="compact"35            weight="plus"36            className="text-ui-fg-base"37          >38            {item.title}39          </Text>40
41          {item.variant_sku && (42            <div className="flex items-center gap-x-1">43              <Text size="small">{item.variant_sku}</Text>44            </div>45          )}46          <Text size="small">47            {item.variant?.options?.map((o) => o.value).join(" · ")}48          </Text>49        </div>50      </div>51
52      <div className="grid grid-cols-3 items-center gap-x-4">53        <div className="flex items-center justify-end gap-x-4">54          <Amount55            className="text-sm text-right justify-end items-end"56            currencyCode={currencyCode}57            // @ts-ignore58            amount={item.detail.unit_price}59            originalAmount={item.unit_price}60          />61        </div>62
63        <div className="flex items-center gap-x-2">64          <div className="w-fit min-w-[27px]">65            <Badge size="xsmall" color="grey">66              <span className="tabular-nums text-xs">{item.quantity}</span>x67            </Badge>68          </div>69
70          <div>71
72            {isItemUpdated && (73              <Badge74                size="2xsmall"75                rounded="full"76                color="orange"77                className="mr-1"78              >79                Modified80              </Badge>81            )}82          </div>83
84          <div className="overflow-visible"></div>85        </div>86
87        <Amount88          className="text-sm text-right justify-end items-end"89          currencyCode={currencyCode}90          amount={item.total}91          originalAmount={originalItem?.total}92        />93      </div>94    </div>95  )96}

You first define the component for one quote item. In the component, you show the item's title, variant SKU, and quantity. You also use the Amount component to show the item's current and previous amounts.

Next, add to the same file the QuoteItems component:

src/admin/components/quote-items.tsx
1export const QuoteItems = ({2  order,3  preview,4}: {5  order: AdminOrder;6  preview: AdminOrderPreview;7}) => {8  const itemsMap = useMemo(() => {9    return new Map(order.items.map((item) => [item.id, item]))10  }, [order])11
12  return (13    <div>14      {preview.items?.map((item) => {15        return (16          <QuoteItem17            key={item.id}18            item={item}19            originalItem={itemsMap.get(item.id)}20            currencyCode={order.currency_code}21          />22        )23      })}24    </div>25  )26}

In this component, you loop over the order's items and show each of them using the QuoteItem component.

Create TotalsBreakdown Component#

Another component you'll need in the quote's UI route is a component that breaks down the totals of the quote's draft order, such as its discount or shipping totals.

Screenshot showcasing where this component will be in the page

Create the file src/admin/components/totals-breakdown.tsx with the following content:

Directory structure after adding the totals breakdown component

src/admin/components/totals-breakdown.tsx
1import { AdminOrder } from "@medusajs/framework/types"2import { Text } from "@medusajs/ui"3import { ReactNode } from "react"4import { formatAmount } from "../utils/format-amount"5
6export const Total = ({7  label,8  value,9  secondaryValue,10  tooltip,11}: {12  label: string;13  value: string | number;14  secondaryValue: string;15  tooltip?: ReactNode;16}) => (17  <div className="grid grid-cols-3 items-center">18    <Text size="small" leading="compact">19      {label} {tooltip}20    </Text>21    <div className="text-right">22      <Text size="small" leading="compact">23        {secondaryValue}24      </Text>25    </div>26
27    <div className="text-right">28      <Text size="small" leading="compact">29        {value}30      </Text>31    </div>32  </div>33)

You first define the Total component, which breaksdown a total item, such as discount. You'll use this component to breakdown the different totals in the TotalsBreakdown component.

Add the TotalsBreakdown component after the Total component:

src/admin/components/totals-breakdown.tsx
1export const TotalsBreakdown = ({ order }: { order: AdminOrder }) => {2  return (3    <div className="text-ui-fg-subtle flex flex-col gap-y-2 px-6 py-4">4      <Total5        label="Discounts"6        secondaryValue=""7        value={8          order.discount_total > 09            ? `- ${formatAmount(order.discount_total, order.currency_code)}`10            : "-"11        }12      />13      {(order.shipping_methods || [])14        .sort((m1, m2) =>15          (m1.created_at as string).localeCompare(m2.created_at as string)16        )17        .map((sm, i) => {18          return (19            <div key={i}>20              <Total21                key={sm.id}22                label={"Shipping"}23                secondaryValue={sm.name}24                value={formatAmount(sm.total, order.currency_code)}25              />26            </div>27          )28        })}29    </div>30  )31}

In this component, you show the different totals of the quote's draft order, such as discounts and shipping totals. You use the Total component to show each total item.

Create Quote Details UI Route#

You can now create the UI route that will show a quote's details in the Medusa Admin.

Create the file src/admin/routes/quote/[id]/page.tsx with the following content:

Diagram showcasing the directory structure after adding the quote details UI route file

src/admin/routes/quote/[id]/page.tsx
1import { CheckCircleSolid } from "@medusajs/icons"2import {3  Button,4  Container,5  Heading,6  Text,7  Toaster,8} from "@medusajs/ui"9import { Link, useNavigate, useParams } from "react-router-dom"10import { useOrderPreview } from "../../../hooks/order-preview"11import { 12  useQuote, 13} from "../../../hooks/quotes"14import { QuoteItems } from "../../../components/quote-items"15import { TotalsBreakdown } from "../../../components/totals-breakdown"16import { formatAmount } from "../../../utils/format-amount"17
18const QuoteDetails = () => {19  const { id } = useParams()20  const navigate = useNavigate()21  const { quote, isLoading } = useQuote(id!, {22    fields:23      "*draft_order.customer",24  })25
26  const { order: preview, isLoading: isPreviewLoading } = useOrderPreview(27    quote?.draft_order_id!,28    {},29    { enabled: !!quote?.draft_order_id }30  )31
32  if (isLoading || !quote) {33    return <></>34  }35
36  if (isPreviewLoading) {37    return <></>38  }39
40  if (!isPreviewLoading && !preview) {41    throw "preview not found"42  }43
44  // TODO render content45}46
47export default QuoteDetails

The QuoteDetails component will render the content of the quote's details page. So far, you retrieve the quote and its preview using the hooks you created earlier. You also render empty components or an error message if the data is still loading or not found.

To add the rendered content, replace the TODO with the following:

src/admin/routes/quote/[id]/page.tsx
1return (2  <div className="flex flex-col gap-y-3">3    <div className="flex flex-col gap-x-4 lg:flex-row xl:items-start">4      <div className="flex w-full flex-col gap-y-3">5        {quote.status === "accepted" && (6          <Container className="divide-y divide-dashed p-0">7            <div className="flex items-center justify-between px-6 py-4">8              <Text className="txt-compact-small">9                <CheckCircleSolid className="inline-block mr-2 text-green-500 text-lg" />10                Quote accepted by customer. Order is ready for processing.11              </Text>12
13              <Button14                size="small"15                onClick={() => navigate(`/orders/${quote.draft_order_id}`)}16              >17                View Order18              </Button>19            </div>20          </Container>21        )}22
23        <Container className="divide-y divide-dashed p-0">24          <div className="flex items-center justify-between px-6 py-4">25            <Heading level="h2">Quote Summary</Heading>26          </div>27          <QuoteItems order={quote.draft_order} preview={preview!} />28          <TotalsBreakdown order={quote.draft_order} />29          <div className=" flex flex-col gap-y-2 px-6 py-4">30            <div className="text-ui-fg-base flex items-center justify-between">31              <Text32                weight="plus"33                className="text-ui-fg-subtle"34                size="small"35                leading="compact"36              >37                Original Total38              </Text>39              <Text40                weight="plus"41                className="text-ui-fg-subtle"42                size="small"43                leading="compact"44              >45                {formatAmount(quote.draft_order.total, quote.draft_order.currency_code)}46              </Text>47            </div>48      49            <div className="text-ui-fg-base flex items-center justify-between">50              <Text51                className="text-ui-fg-subtle text-semibold"52                size="small"53                leading="compact"54                weight="plus"55              >56                Quote Total57              </Text>58              <Text59                className="text-ui-fg-subtle text-bold"60                size="small"61                leading="compact"62                weight="plus"63              >64                {formatAmount(preview!.summary.current_order_total, quote.draft_order.currency_code)}65              </Text>66            </div>67          </div>68
69          {/* TODO add actions later */}70        </Container>71
72      </div>73
74      <div className="mt-2 flex w-full max-w-[100%] flex-col gap-y-3 xl:mt-0 xl:max-w-[400px]">75        <Container className="divide-y p-0">76          <div className="flex items-center justify-between px-6 py-4">77            <Heading level="h2">Customer</Heading>78          </div>79
80          <div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">81            <Text size="small" weight="plus" leading="compact">82              Email83            </Text>84
85            <Link86              className="text-sm text-pretty text-blue-500"87              to={`/customers/${quote.draft_order?.customer?.id}`}88              onClick={(e) => e.stopPropagation()}89            >90              {quote.draft_order?.customer?.email}91            </Link>92          </div>93        </Container>94      </div>95    </div>96
97    <Toaster />98  </div>99)

You first check if the quote has been accepted by the customer, and show a banner to view the created order if so.

Next, you use the QuoteItems and TotalsBreakdown components that you created to show the quote's items and totals. You also show the original and current totals of the quote, where the original total is the total of the draft order before any changes are made to its items.

Finally, you show the customer's email and a link to view their details.

Test Quote Details UI Route#

To test the quote details UI route, start the Medusa application:

Then, open the Medusa Admin dashboard at http://localhost:9000/app and login using the credentials you set up earlier.

Next, click on Quotes in the sidebar, which will open the list of quotes UI route you created earlier. Click on one of the quotes to view its details page.

On the quote's details page, you can see the quote's items, its totals, and the customer's details. In the next steps, you'll add management features to the page.

Quote details page in Medusa Admin


Step 10: Add Merchant Reject Quote Feature#

After the merchant or admin views the quote, they can choose to either reject it, send the quote back to the customer to review it, or make changes to the quote's prices and quantities.

In this step, you'll implement the functionality to reject a quote from the quote's details page. This will include:

  1. Implementing the workflow to reject a quote.
  2. Adding the API route to reject a quote that uses the workflow.
  3. Add a hook in admin customizations that sends a request to the reject quote API route.
  4. Add a button to reject the quote in the quote's details page.

Implement Merchant Reject Quote Workflow#

To reject a quote, you'll need to create a workflow that will handle the rejection process. The workflow has the following steps:

As mentioned before, the useQueryGraphStep is provided by Medusa's @medusajs/medusa/core-flows package. So, you'll only implement the remaining steps.

validateQuoteNotAccepted

The second step of the merchant rejection workflow ensures that a quote isn't already accepted, as it can't be rejected afterwards.

To create the step, create the file src/workflows/steps/validate-quote-not-accepted.ts with the following content:

Diagram showcasing the directory structure after adding the validate quote rejection step file

src/workflows/steps/validate-quote-not-accepted.ts
1import { MedusaError } from "@medusajs/framework/utils"2import { createStep } from "@medusajs/framework/workflows-sdk"3import { InferTypeOf } from "@medusajs/framework/types"4import { Quote, QuoteStatus } from "../../modules/quote/models/quote"5
6type StepInput = {7  quote: InferTypeOf<typeof Quote>8}9
10export const validateQuoteNotAccepted = createStep(11  "validate-quote-not-accepted",12  async function ({ quote }: StepInput) {13    if (quote.status === QuoteStatus.ACCEPTED) {14      throw new MedusaError(15        MedusaError.Types.INVALID_DATA,16        `Quote is already accepted by customer`17      )18    }19  }20)

You create a step that accepts a quote as an input and throws an error if the quote's status is accepted, as you can't reject a quote that has been accepted by the customer.

updateQuoteStatusStep

In the last step of the workflow, you'll change the workflow's status to merchant_rejected. So, you'll create a step that can be used to update a quote's status.

Create the file src/workflows/steps/update-quotes.ts with the following content:

Diagram showcasing the directory structure after adding the update quotes step file

src/workflows/steps/update-quotes.ts
1import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"2import { QUOTE_MODULE } from "../../modules/quote"3import { QuoteStatus } from "../../modules/quote/models/quote"4import QuoteModuleService from "../../modules/quote/service"5
6type StepInput = {7  id: string;8  status?: QuoteStatus;9}[]10
11export const updateQuotesStep = createStep(12  "update-quotes",13  async (data: StepInput, { container }) => {14    const quoteModuleService: QuoteModuleService = container.resolve(15      QUOTE_MODULE16    )17
18    const dataBeforeUpdate = await quoteModuleService.listQuotes(19      { id: data.map((d) => d.id) }20    )21
22    const updatedQuotes = await quoteModuleService.updateQuotes(data)23
24    return new StepResponse(updatedQuotes, {25      dataBeforeUpdate,26    })27  },28  async (revertInput, { container }) => {29    if (!revertInput) {30      return31    }32
33    const quoteModuleService: QuoteModuleService = container.resolve(34      QUOTE_MODULE35    )36
37    await quoteModuleService.updateQuotes(38      revertInput.dataBeforeUpdate39    )40  }41)

This step accepts an array of quotes to update their status. In the step function, you resolve the Quote Module's service. Then, you retrieve the quotes' original data so that you can pass them to the compensation function. Finally, you update the quotes' data and return the updated quotes.

In the compensation function, you resolve the Quote Module's service and update the quotes with their original data.

Implement Workflow

You can now implement the merchant-rejection workflow. Create the file src/workflows/merchant-reject-quote.ts with the following content:

Diagram showcasing the directory structure after adding the merchant reject quote workflow file

src/workflows/merchant-reject-quote.ts
1import { useQueryGraphStep } from "@medusajs/core-flows"2import { createWorkflow } from "@medusajs/workflows-sdk"3import { QuoteStatus } from "../modules/quote/models/quote"4import { validateQuoteNotAccepted } from "./steps/validate-quote-not-accepted"5import { updateQuotesStep } from "./steps/update-quotes"6
7type WorkflowInput = {8  quote_id: string;9}10
11export const merchantRejectQuoteWorkflow = createWorkflow(12  "merchant-reject-quote-workflow",13  (input: WorkflowInput) => {14    // @ts-ignore15    const { data: quotes } = useQueryGraphStep({16      entity: "quote",17      fields: ["id", "status"],18      filters: { id: input.quote_id },19      options: {20        throwIfKeyNotFound: true,21      },22    })23
24    validateQuoteNotAccepted({ 25      // @ts-ignore26      quote: quotes[0],27    })28
29    updateQuotesStep([30      {31        id: input.quote_id,32        status: QuoteStatus.MERCHANT_REJECTED,33      },34    ])35  }36)

You create a workflow that accepts the ID of a quote to reject. In the workflow, you:

  1. Use the useQueryGraphStep to retrieve the quote's details.
  2. Validate that the quote isn't already accepted using the validateQuoteNotAccepted.
  3. Update the quote's status to merchant_rejected using the updateQuotesStep.

You'll use this workflow next in an API route that allows a merchant to reject a quote.

Add Admin Reject Quote API Route#

You'll now add the API route that allows a merchant to reject a quote. The route will use the merchantRejectQuoteWorkflow you created in the previous step.

Create the file src/api/admin/quotes/[id]/reject/route.ts with the following content:

Diagram showcasing the directory structure after adding the reject quote API route file

src/api/admin/quotes/[id]/reject/route.ts
1import type {2  AuthenticatedMedusaRequest,3  MedusaResponse,4} from "@medusajs/framework/http"5import { ContainerRegistrationKeys } from "@medusajs/framework/utils"6import { merchantRejectQuoteWorkflow } from "../../../../../workflows/merchant-reject-quote"7
8export const POST = async (9  req: AuthenticatedMedusaRequest,10  res: MedusaResponse11) => {12  const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)13  const { id } = req.params14
15  await merchantRejectQuoteWorkflow(req.scope).run({16    input: {17      quote_id: id,18    },19  })20
21  const {22    data: [quote],23  } = await query.graph(24    {25      entity: "quote",26      filters: { id },27      fields: req.queryConfig.fields,28    },29    { throwIfKeyNotFound: true }30  )31
32  res.json({ quote })33}

You create a POST route handler, which will expose a POST API route at /admin/quotes/:id/reject. In the route handler, you run the merchantRejectQuoteWorkflow with the quote's ID as input. You then retrieve the updated quote using Query and return it in the response.

Notice that you can pass req.queryConfig.fields to the query.graph method because you've applied the validateAndTransformQuery middleware before to all routes starting with /admin/quotes.

Add Reject Quote Hook#

Now that you have the API route, you can add a React hook in the admin customizations that sends a request to the route to reject a quote.

In src/admin/hooks/quotes.tsx add the following new hook:

src/admin/hooks/quotes.tsx
1// other imports...2import {3  useMutation,4  UseMutationOptions,5} from "@tanstack/react-query"6
7// ...8
9export const useRejectQuote = (10  id: string,11  options?: UseMutationOptions<AdminQuoteResponse, FetchError, void>12) => {13  const queryClient = useQueryClient()14
15  const rejectQuote = async (id: string) =>16    sdk.client.fetch<AdminQuoteResponse>(`/admin/quotes/${id}/reject`, {17      method: "POST",18    })19
20  return useMutation({21    mutationFn: () => rejectQuote(id),22    onSuccess: (data: AdminQuoteResponse, variables: any, context: any) => {23      queryClient.invalidateQueries({24        queryKey: [orderPreviewQueryKey, id],25      })26
27      queryClient.invalidateQueries({28        queryKey: ["quote", id],29      })30
31      queryClient.invalidateQueries({32        queryKey: ["quote", "list"],33      })34
35      options?.onSuccess?.(data, variables, context)36    },37    ...options,38  })39}

You add a useRejectQuote hook that accepts the quote's ID and optional options as parameters. In the hook, you use the useMutation hook to define the mutation action that sends a request to the reject quote API route.

When the mutation is invoked, the hook sends a request to the API route to reject the quote, then invalidates all data related to the quote in the query client, which will trigger a re-fetch of the data.

Add Reject Quote Button#

Finally, you can add a button to the quote's details page that allows a merchant to reject the quote.

In src/admin/routes/quote/[id]/page.tsx, add the following imports:

src/admin/routes/quote/[id]/page.tsx
1import {2  toast,3  usePrompt,4} from "@medusajs/ui"5import { useEffect, useState } from "react"6import { 7  useRejectQuote, 8} from "../../../hooks/quotes"

Then, in the QuoteDetails component, add the following after the useOrderPreview hook usage:

src/admin/routes/quote/[id]/page.tsx
1const prompt = usePrompt()2const { mutateAsync: rejectQuote, isPending: isRejectingQuote } =3  useRejectQuote(id!)4const [showRejectQuote, setShowRejectQuote] = useState(false)5
6useEffect(() => {7  if (8    ["customer_rejected", "merchant_rejected", "accepted"].includes(9      quote?.status!10    )11  ) {12    setShowRejectQuote(false)13  } else {14    setShowRejectQuote(true)15  }16}, [quote])17
18const handleRejectQuote = async () => {19  const res = await prompt({20    title: "Reject quote?",21    description:22      "You are about to reject this customer's quote. Do you want to continue?",23    confirmText: "Continue",24    cancelText: "Cancel",25    variant: "confirmation",26  })27
28  if (res) {29    await rejectQuote(void 0, {30      onSuccess: () =>31        toast.success("Successfully rejected customer's quote"),32      onError: (e) => toast.error(e.message),33    })34  }35}

First, you initialize the following variables:

  1. prompt: A function that you'll use to show a confirmation pop-up when the merchant tries to reject the quote. The usePrompt hook is available from the Medusa UI package.
  2. rejectQuote and isRejectingQuote: both are returned by the useRejectQuote hook. The rejectQuote function invokes the mutation, rejecting the quote; isRejectingQuote is a boolean that indicates if the mutation is in progress.
  3. showRejectQuote: A boolean that indicates whether the "Reject Quote" button should be shown. The button is shown if the quote's status is not customer_rejected, merchant_rejected, or accepted. This state variable is changed based on the quote's status in the useEffect hook.

You also define a handleRejectQuote function that will be called when the merchant clicks the reject quote button. The function shows a confirmation pop-up using the prompt function. If the user confirms the action, the function calls the rejectQuote function to reject the quote.

Finally, find the TODO in the return statement and replace it with the following:

src/admin/routes/quote/[id]/page.tsx
1<div className="bg-ui-bg-subtle flex items-center justify-end gap-x-2 rounded-b-xl px-4 py-4">2  {showRejectQuote && (3    <Button4      size="small"5      variant="secondary"6      onClick={() => handleRejectQuote()}7      disabled={isRejectingQuote}8    >9      Reject Quote10    </Button>11  )}12</div>

In this code snippet, you show the reject quote button if the showRejectQuote state is true. When the button is clicked, you call the handleRejectQuote function to reject the quote.

Test Reject Quote Feature#

To test the reject quote feature, start the Medusa application:

Then, open the Medusa Admin dashboard at http://localhost:9000/app and login using the credentials you set up earlier.

Next, open a quote's details page. You'll find a new "Reject Quote" button. If you click on it and confirm rejecting the quote, the quote will be rejected, and a success message will be shown.

Quote details page with reject quote button in Medusa Admin


Step 11: Add Merchant Send Quote Feature#

Another action that a merchant can take on a quote is to send the quote back to the customer for review. The customer can then reject or accept the quote, which would convert it to an order.

In this step, you'll implement the functionality to send a quote back to the customer for review. This will include:

  1. Implementing the workflow to send a quote back to the customer.
  2. Adding the API route to send a quote back to the customer that uses the workflow.
  3. Add a hook in admin customizations that sends a request to the send quote API route.
  4. Add a button to send the quote back to the customer in the quote's details page.

Implement Merchant Send Quote Workflow#

You'll implement the logic of sending the quote in a workflow. The workflow has the following steps:

All the steps are available for use, so you can implement the workflow directly.

Create the file src/workflows/merchant-send-quote.ts with the following content:

Directory structure after adding the merchant send quote workflow file

src/workflows/merchant-send-quote.ts
1import { useQueryGraphStep } from "@medusajs/core-flows"2import { createWorkflow } from "@medusajs/workflows-sdk"3import { QuoteStatus } from "../modules/quote/models/quote"4import { updateQuotesStep } from "./steps/update-quotes"5import { validateQuoteNotAccepted } from "./steps/validate-quote-not-accepted"6
7type WorkflowInput = {8  quote_id: string;9}10
11export const merchantSendQuoteWorkflow = createWorkflow(12  "merchant-send-quote-workflow",13  (input: WorkflowInput) => {14    // @ts-ignore15    const { data: quotes } = useQueryGraphStep({16      entity: "quote",17      fields: ["id", "status"],18      filters: { id: input.quote_id },19      options: {20        throwIfKeyNotFound: true,21      },22    })23
24    validateQuoteNotAccepted({25      // @ts-ignore26      quote: quotes[0],27    })28
29    updateQuotesStep([30      {31        id: input.quote_id,32        status: QuoteStatus.PENDING_CUSTOMER,33      },34    ])35  }36)

You create a workflow that accepts the ID of a quote to send back to the customer. In the workflow, you:

  1. Use the useQueryGraphStep to retrieve the quote's details.
  2. Validate that the quote can be sent back to the customer using the validateQuoteNotAccepted step.
  3. Update the quote's status to pending_customer using the updateQuotesStep.

You'll use this workflow next in an API route that allows a merchant to send a quote back to the customer.

Add Send Quote API Route#

You'll now add the API route that allows a merchant to send a quote back to the customer. The route will use the merchantSendQuoteWorkflow you created in the previous step.

Create the file src/api/admin/quotes/[id]/send/route.ts with the following content:

Directory structure after adding the send quote API route file

src/api/admin/quotes/[id]/send/route.ts
1import type {2  AuthenticatedMedusaRequest,3  MedusaResponse,4} from "@medusajs/framework/http"5import { ContainerRegistrationKeys } from "@medusajs/framework/utils"6import { 7  merchantSendQuoteWorkflow,8} from "../../../../../workflows/merchant-send-quote"9
10export const POST = async (11  req: AuthenticatedMedusaRequest,12  res: MedusaResponse13) => {14  const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)15  const { id } = req.params16
17  await merchantSendQuoteWorkflow(req.scope).run({18    input: {19      quote_id: id,20    },21  })22
23  const {24    data: [quote],25  } = await query.graph(26    {27      entity: "quote",28      filters: { id },29      fields: req.queryConfig.fields,30    },31    { throwIfKeyNotFound: true }32  )33
34  res.json({ quote })35}

You create a POST route handler, which will expose a POST API route at /admin/quotes/:id/send. In the route handler, you run the merchantSendQuoteWorkflow with the quote's ID as input. You then retrieve the updated quote using Query and return it in the response.

Notice that you can pass req.queryConfig.fields to the query.graph method because you've applied the validateAndTransformQuery middleware before to all routes starting with /admin/quotes.

Add Send Quote Hook#

Now that you have the API route, you can add a React hook in the admin customizations that sends a request to the quote send API route.

In src/admin/hooks/quotes.tsx add the new hook:

src/admin/hooks/quotes.tsx
1export const useSendQuote = (2  id: string,3  options?: UseMutationOptions<AdminQuoteResponse, FetchError, void>4) => {5  const queryClient = useQueryClient()6
7  const sendQuote = async (id: string) =>8    sdk.client.fetch<AdminQuoteResponse>(`/admin/quotes/${id}/send`, {9      method: "POST",10    })11
12  return useMutation({13    mutationFn: () => sendQuote(id),14    onSuccess: (data: any, variables: any, context: any) => {15      queryClient.invalidateQueries({16        queryKey: [orderPreviewQueryKey, id],17      })18
19      queryClient.invalidateQueries({20        queryKey: ["quote", id],21      })22
23      queryClient.invalidateQueries({24        queryKey: ["quote", "list"],25      })26
27      options?.onSuccess?.(data, variables, context)28    },29    ...options,30  })31}

You add a useSendQuote hook that accepts the quote's ID and optional options as parameters. In the hook, you use the useMutation hook to define the mutation action that sends a request to the send quote API route.

When the mutation is invoked, the hook sends a request to the send quote API route, then invalidates all data related to the quote in the query client, which will trigger a re-fetch of the data.

Add Send Quote Button#

Finally, you can add a button to the quote's details page that allows a merchant to send the quote back to the customer for review.

First, add the following import to the src/admin/routes/quote/[id]/page.tsx file:

src/admin/routes/quote/[id]/page.tsx
1import { 2  useSendQuote,3} from "../../../hooks/quotes"

Then, after the useRejectQuote hook usage, add the following:

src/admin/routes/quote/[id]/page.tsx
1const { mutateAsync: sendQuote, isPending: isSendingQuote } = useSendQuote(2  id!3)4const [showSendQuote, setShowSendQuote] = useState(false)

You initialize the following variables:

  1. sendQuote and isSendingQuote: Data returned by the useSendQuote hook. The sendQuote function invokes the mutation, sending the quote back to the customer; isSendingQuote is a boolean that indicates if the mutation is in progress.
  2. showSendQuote: A boolean that indicates whether the "Send Quote" button should be shown.

Next, update the existing useEffect hook to change showSendQuote based on the quote's status:

src/admin/routes/quote/[id]/page.tsx
1useEffect(() => {2  if (["pending_merchant", "customer_rejected"].includes(quote?.status!)) {3    setShowSendQuote(true)4  } else {5    setShowSendQuote(false)6  }7
8  if (9    ["customer_rejected", "merchant_rejected", "accepted"].includes(10      quote?.status!11    )12  ) {13    setShowRejectQuote(false)14  } else {15    setShowRejectQuote(true)16  }17}, [quote])

The useEffect hook now updates both the showSendQuote and showRejectQuote states based on the quote's status. The "Send Quote" button is hidden if the quote's status is not pending_merchant or customer_rejected.

Then, after the handleRejectQuote function, add the following handleSendQuote function:

src/admin/routes/quote/[id]/page.tsx
1const handleSendQuote = async () => {2  const res = await prompt({3    title: "Send quote?",4    description:5      "You are about to send this quote to the customer. Do you want to continue?",6    confirmText: "Continue",7    cancelText: "Cancel",8    variant: "confirmation",9  })10
11  if (res) {12    await sendQuote(13      void 0,14      {15        onSuccess: () => toast.success("Successfully sent quote to customer"),16        onError: (e) => toast.error(e.message),17      }18    )19  }20}

You define a handleSendQuote function that will be called when the merchant clicks the "Send Quote" button. The function shows a confirmation pop-up using the prompt hook. If the user confirms the action, the function calls the sendQuote function to send the quote back to the customer.

Finally, add the following after the reject quote button in the return statement:

src/admin/routes/quote/[id]/page.tsx
1{showSendQuote && (2  <Button3    size="small"4    variant="secondary"5    onClick={() => handleSendQuote()}6    disabled={isSendingQuote || isRejectingQuote}7  >8    Send Quote9  </Button>10)}

In this code snippet, you show the "Send Quote" button if the showSendQuote state is true. When the button is clicked, you call the handleSendQuote function to send the quote back to the customer.

Test Send Quote Feature#

To test the send quote feature, start the Medusa application:

Then, open the Medusa Admin dashboard at http://localhost:9000/app and login using the credentials you set up earlier.

Next, open a quote's details page. You'll find a new "Send Quote" button. If you click on it and confirm sending the quote, the quote will be sent back to the customer, and a success message will be shown.

NoteYou'll later add the feature to update the quote item's details before sending the quote back to the customer.

Quote details page with send quote button in Medusa Admin


Step 12: Add Customer Preview Order API Route#

When the merchant sends back the quote to the customer, you want to show the customer the details of the quote and the order that would be created if they accept the quote. This helps the customer decide whether to accept or reject the quote (which you'll implement next).

In this step, you'll add the API route that allows a customer to preview a quote's order.

To create the API route, create the file src/api/store/customers/me/quotes/[id]/preview/route.ts with the following content:

Directory structure after adding the customer preview order API route file

src/api/store/customers/me/quotes/[id]/preview/route.ts
1import type {2  AuthenticatedMedusaRequest,3  MedusaResponse,4} from "@medusajs/framework/http"5import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"6
7export const GET = async (8  req: AuthenticatedMedusaRequest,9  res: MedusaResponse10) => {11  const { id } = req.params12  const query = req.scope.resolve(13    ContainerRegistrationKeys.QUERY14  )15
16  const {17    data: [quote],18  } = await query.graph(19    {20      entity: "quote",21      filters: { id },22      fields: req.queryConfig.fields,23    },24    { throwIfKeyNotFound: true }25  )26
27  const orderModuleService = req.scope.resolve(28    Modules.ORDER29  )30
31  const preview = await orderModuleService.previewOrderChange(32    quote.draft_order_id33  )34
35  res.status(200).json({36    quote: {37      ...quote,38      order_preview: preview,39    },40  })41}

You create a GET route handler, which will expose a GET API route at /store/customers/me/quotes/:id/preview. In the route handler, you retrieve the quote's details using Query, then preview the order that would be created from the quote using the previewOrderChange method from the Order Module's service. Finally, you return the quote and its order preview in the response.

Notice that you're using the req.queryConfig.fields object in the query.graph method because you've applied the validateAndTransformQuery middleware before to all routes starting with /store/customers/me/quotes.

Test Customer Preview Order API Route#

To test the customer preview order API route, start the Medusa application:

Then, grab the ID of a quote placed by a customer that you have their authentication token. You can find the quote ID in the URL when viewing the quote's details page in the Medusa Admin dashboard.

Finally, send the following request to get a preview of the customer's quote and order:

Code
1curl 'http://localhost:9000/store/customers/me/quotes/{quote_id}/preview' \2-H 'x-publishable-api-key: {your_publishable_api_key}' \3-H 'Authorization: Bearer {token}'

Make sure to replace:

  • {quote_id} with the ID of the quote you want to preview.
  • {your_publishable_api_key} with your publishable API key.
  • {token} with the customer's authentication token.

You'll receive in the response the quote's details with the order preview. You can show the customer these details in the storefront.


Step 13: Add Customer Reject Quote Feature#

After the customer previews the quote and its order, they can choose to reject the quote. When the customer rejects the quote, the quote's status is changed to customer_rejected. The merchant will still be able to update the quote and send it back to the customer for review.

In this step, you'll implement the functionality to reject a quote from the customer's perspective. This will include:

  1. Implementing the workflow to reject a quote as a customer.
  2. Adding the API route to allow customers to reject a quote using the workflow.

Implement Customer Reject Quote Workflow#

To reject a quote from the customer's perspective, you'll need to create a workflow that will handle the rejection process. The workflow has the following steps:

All the steps are available for use, so you can implement the workflow directly.

Create the file src/workflows/customer-reject-quote.ts with the following content:

Directory structure after adding the customer reject quote workflow file

src/workflows/customer-reject-quote.ts
1import { useQueryGraphStep } from "@medusajs/core-flows"2import { createWorkflow } from "@medusajs/workflows-sdk"3import { QuoteStatus } from "../modules/quote/models/quote"4import { updateQuotesStep } from "./steps/update-quotes"5import { validateQuoteNotAccepted } from "./steps/validate-quote-not-accepted"6
7type WorkflowInput = {8  quote_id: string;9  customer_id: string;10}11
12export const customerRejectQuoteWorkflow = createWorkflow(13  "customer-reject-quote-workflow",14  (input: WorkflowInput) => {15    // @ts-ignore16    const { data: quotes } = useQueryGraphStep({17      entity: "quote",18      fields: ["id", "status"],19      filters: { id: input.quote_id, customer_id: input.customer_id },20      options: {21        throwIfKeyNotFound: true,22      },23    })24
25    validateQuoteNotAccepted({ 26      // @ts-ignore27      quote: quotes[0],28    })29
30    updateQuotesStep([31      {32        id: input.quote_id,33        status: QuoteStatus.CUSTOMER_REJECTED,34      },35    ])36  }37)

You create a workflow that accepts the IDs of the quote to reject and the customer rejecting it. In the workflow, you:

  1. Use the useQueryGraphStep to retrieve the quote's details. Notice that you pass the IDs of the quote and the customer as filters to ensure that the quote belongs to the customer.
  2. Validate that the quote isn't already accepted using the validateQuoteNotAccepted step.
  3. Update the quote's status to customer_rejected using the updateQuotesStep.

You'll use this workflow next in an API route that allows a customer to reject a quote.

Add Customer Reject Quote API Route#

You'll now add the API route that allows a customer to reject a quote. The route will use the customerRejectQuoteWorkflow you created in the previous step.

Create the file src/api/store/customers/me/quotes/[id]/reject/route.ts with the following content:

Directory structure after adding the customer reject quote API route file

src/api/store/customers/me/quotes/[id]/reject/route.ts
1import type { 2  AuthenticatedMedusaRequest, 3  MedusaResponse,4} from "@medusajs/framework/http"5import { ContainerRegistrationKeys } from "@medusajs/framework/utils"6import { 7  customerRejectQuoteWorkflow,8} from "../../../../../../../workflows/customer-reject-quote"9
10export const POST = async (11  req: AuthenticatedMedusaRequest,12  res: MedusaResponse13) => {14  const { id } = req.params15  const query = req.scope.resolve(16    ContainerRegistrationKeys.QUERY17  )18
19  await customerRejectQuoteWorkflow(req.scope).run({20    input: {21      quote_id: id,22      customer_id: req.auth_context.actor_id,23    },24  })25
26  const {27    data: [quote],28  } = await query.graph(29    {30      entity: "quote",31      filters: { id },32      fields: req.queryConfig.fields,33    },34    { throwIfKeyNotFound: true }35  )36
37  return res.json({ quote })38}

You create a POST route handler, which will expose a POST API route at /store/customers/me/quotes/:id/reject. In the route handler, you run the customerRejectQuoteWorkflow with the quote's ID as input. You then retrieve the updated quote using Query and return it in the response.

Notice that you can pass req.queryConfig.fields to the query.graph method because you've applied the validateAndTransformQuery middleware before to all routes starting with /store/customers/me/quotes.

Test Customer Reject Quote Feature#

To test the customer reject quote feature, start the Medusa application:

Then, send a request to reject a quote for the authenticated customer:

Code
1curl 'http://localhost:9000/store/customers/me/quotes/{quote_id}/reject' \2-H 'x-publishable-api-key: {your_publishable_api_key}' \3-H 'Authorization: Bearer {token}'

Make sure to replace:

After sending the request, the quote will be rejected, and the updated quote will be returned in the response. You can also view the quote from the Medusa Admin dashboard, where you'll find its status has changed.


Step 14: Add Customer Accept Quote Feature#

The customer alternatively can choose to accept a quote after previewing it. When the customer accepts a quote, the quote's draft order should become an order whose payment can be processed and items fulfilled. No further changes can be made on the quote after it's accepted.

In this step, you'll implement the functionality to allow a customer to accept a quote. This will include:

  1. Implementing the workflow to accept a quote as a customer.
  2. Adding the API route to allow customers to accept a quote using the workflow.

Implement Customer Accept Quote Workflow#

You'll implement the quote acceptance logic in a workflow. The workflow has the following steps:

You only need to implement the validateQuoteCanAcceptStep step before implementing the workflow, as the other steps are already available for use.

validateQuoteCanAcceptStep

In the validateQuoteCanAcceptStep, you'll validate whether the customer can accept the quote. The customer can only accept a quote if the quote's status is pending_customer, meaning the merchant sent the quote back to the customer for review.

Create the file src/workflows/steps/validate-quote-can-accept.ts with the following content:

Directory structure after adding the validate quote can accept step file

src/workflows/steps/validate-quote-can-accept.ts
1import { MedusaError } from "@medusajs/framework/utils"2import { createStep } from "@medusajs/framework/workflows-sdk"3import { InferTypeOf } from "@medusajs/framework/types"4import { Quote, QuoteStatus } from "../../modules/quote/models/quote"5
6type StepInput = {7  quote: InferTypeOf<typeof Quote>8}9
10export const validateQuoteCanAcceptStep = createStep(11  "validate-quote-can-accept",12  async function ({ quote }: StepInput) {13    if (quote.status !== QuoteStatus.PENDING_CUSTOMER) {14      throw new MedusaError(15        MedusaError.Types.INVALID_DATA,16        `Cannot accept quote when quote status is ${quote.status}`17      )18    }19  }20)

You create a step that accepts a quote as input. In the step function, you throw an error if the quote's status is not pending_customer.

Implement Workflow

You can now implement the workflow that accepts a quote for a customer. Create the file src/workflows/customer-accept-quote.ts with the following content:

Directory structure after adding the customer accept quote workflow file

src/workflows/customer-accept-quote.ts
1import {2  confirmOrderEditRequestWorkflow,3  updateOrderWorkflow,4  useQueryGraphStep,5} from "@medusajs/core-flows"6import { OrderStatus } from "@medusajs/framework/utils"7import { createWorkflow } from "@medusajs/workflows-sdk"8import { validateQuoteCanAcceptStep } from "./steps/validate-quote-can-accept"9import { QuoteStatus } from "../modules/quote/models/quote"10import { updateQuotesStep } from "./steps/update-quotes"11
12type WorkflowInput = {13  quote_id: string;14  customer_id: string;15};16
17export const customerAcceptQuoteWorkflow = createWorkflow(18  "customer-accept-quote-workflow",19  (input: WorkflowInput) => {20    // @ts-ignore21    const { data: quotes } = useQueryGraphStep({22      entity: "quote",23      fields: ["id", "draft_order_id", "status"],24      filters: { id: input.quote_id, customer_id: input.customer_id },25      options: {26        throwIfKeyNotFound: true,27      },28    })29
30    validateQuoteCanAcceptStep({ 31      // @ts-ignore32      quote: quotes[0],33    })34
35    updateQuotesStep([{ 36      id: input.quote_id, 37      status: QuoteStatus.ACCEPTED,38    }])39
40    confirmOrderEditRequestWorkflow.runAsStep({41      input: {42        order_id: quotes[0].draft_order_id,43        confirmed_by: input.customer_id,44      },45    })46
47    updateOrderWorkflow.runAsStep({48      input:{ 49        id: quotes[0].draft_order_id,50        // @ts-ignore51        status: OrderStatus.PENDING,52        is_draft_order: false,53      },54    })55  }56)

You create a workflow that accepts the IDs of the quote to accept and the customer accepting it. In the workflow, you:

  1. Use the useQueryGraphStep to retrieve the quote's details. You pass the IDs of the quotes and the customer as filters to ensure that the quote belongs to the customer.
  2. Validate that the quote can be accepted using the validateQuoteCanAcceptStep.
  3. Update the quote's status to accepted using the updateQuotesStep.
  4. Confirm the changes made on the draft order using the confirmOrderEditRequestWorkflow executed as a step. This is useful when you soon add the admin functionality to edit the quote items. Any changes that the admin has made will be applied on the draft order using this step.
  5. Update the draft order to change its status and convert it into an order using the updateOrderWorkflow executed as a step.

You'll use this workflow next in an API route that allows a customer to accept a quote.

Add Customer Accept Quote API Route#

You'll now add the API route that allows a customer to accept a quote. The route will use the customerAcceptQuoteWorkflow you created in the previous step.

Create the file src/api/store/customers/me/quotes/[id]/accept/route.ts with the following content:

Directory structure after adding the customer accept quote API route file

src/api/store/customers/me/quotes/[id]/accept/route.ts
1import type {2  AuthenticatedMedusaRequest,3  MedusaResponse,4} from "@medusajs/framework/http"5import { ContainerRegistrationKeys } from "@medusajs/framework/utils"6import { 7  customerAcceptQuoteWorkflow,8} from "../../../../../../../workflows/customer-accept-quote"9
10export const POST = async (11  req: AuthenticatedMedusaRequest,12  res: MedusaResponse13) => {14  const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)15  const { id } = req.params16
17  await customerAcceptQuoteWorkflow(req.scope).run({18    input: {19      quote_id: id,20      customer_id: req.auth_context.actor_id,21    },22  })23
24  const {25    data: [quote],26  } = await query.graph(27    {28      entity: "quote",29      filters: { id },30      fields: req.queryConfig.fields,31    },32    { throwIfKeyNotFound: true }33  )34
35  return res.json({ quote })36}

You create a POST route handler, which will expose a POST API route at /store/customers/me/quotes/:id/accept. In the route handler, you run the customerAcceptQuoteWorkflow with the quote's ID as input. You then retrieve the updated quote using Query and return it in the response.

Notice that you can pass req.queryConfig.fields to the query.graph method because you've applied the validateAndTransformQuery middleware before to all routes starting with /store/customers/me/quotes.

Test Customer Accept Quote Feature#

To test the customer accept quote feature, start the Medusa application:

Then, send a request to accept a quote for the authenticated customer:

Code
1curl 'http://localhost:9000/store/customers/me/quotes/{quote_id}/accept' \2-H 'x-publishable-api-key: {your_publishable_api_key}' \3-H 'Authorization: Bearer {token}'

Make sure to replace:

After sending the request, the quote will be accepted, and the updated quote will be returned in the response.

You can also view the quote from the Medusa Admin dashboard, where you'll find its status has changed. The quote will also have an order, which you can view in the Orders page or using the "View Order" button on the quote's details page.

View order button on quote's details page in Medusa Admin


Step 15: Edit Quote Items UI Route#

The last feature you'll add is allowing merchants or admin users to make changes to the quote's items. This includes updating the item's quantity and price.

Since you're using an order change to manage edits to the quote's draft orders, you don't need to implement customizations on the server side, such as adding workflows or API routes. Instead, you'll only add a new UI route in the Medusa Admin that uses the Order Edit API routes to provide the functionality to edit the quote's items.

NoteOrder changes also allow you to add or remove items from the quote. However, for simplicity, this guide only covers how to update the item's quantity and price. Refer to the Order Change documentation to learn more.

Edit quote items page in Medusa Admin

In this step, you'll add a new UI route to manage the quote's items. This will include:

  1. Adding hooks to send requests to Medusa's Order Edits API routes.
  2. Implement the components you'll use within the UI route.
  3. Add the new UI route to the Medusa Admin.

Intermission: Order Editing Overview#

Before you start implementing the customizations, here's a quick overview of how order editing works in Medusa.

When the admin wants to edit an order's items, Medusa creates an order change. You've already implemented this part on quote creation.

Then, when the admin makes an edit to an item, Medusa saves that edit but without applying it to the order or finalizing the edit. This allows the admin to make multiple edits before finalizing the changes.

Once the admin is finished editing, they can confirm the order edit, which finalizes it to later be applied on the order. You've already implemented applying the order edit on the order when the customer accepts the quote.

So, you still need two implement two aspects: updating the quote items, and confirming the order edit. You'll implement these in the next steps.

Add Hooks#

To implement the edit quote items functionality, you'll need two hooks:

  1. A hook that updates a quote item's quantity and price using the Order Edits API routes.
  2. A hook that confirms the edit of the items using the Order Edits API routes.

Update Quote Item Hook

The first hook updates an item's quantity and price using the Order Edits API routes. You'll use this whenever an admin updates an item's quantity or price.

In src/admin/hooks/quotes.tsx, add the following hook:

src/admin/hooks/quotes.tsx
1// other imports...2import { HttpTypes } from "@medusajs/framework/types"3
4// ...5
6export const useUpdateQuoteItem = (7  id: string,8  options?: UseMutationOptions<9    HttpTypes.AdminOrderEditPreviewResponse,10    FetchError,11    UpdateQuoteItemParams12  >13) => {14  const queryClient = useQueryClient()15
16  return useMutation({17    mutationFn: ({18      itemId,19      ...payload20    }: UpdateQuoteItemParams) => {21      return sdk.admin.orderEdit.updateOriginalItem(id, itemId, payload)22    },23    onSuccess: (data: any, variables: any, context: any) => {24      queryClient.invalidateQueries({25        queryKey: [orderPreviewQueryKey, id],26      })27
28      options?.onSuccess?.(data, variables, context)29    },30    ...options,31  })32}

You create a useUpdateQuoteItem hook that accepts the quote's ID and optional options as parameters. In the hook, you use the useMutation hook to define the mutation action that updates an item's quantity and price using the sdk.admin.orderEdit.updateOriginalItem method.

When the mutation is invoked, the hook invalidates the quote's data in the query client, which will trigger a re-fetch of the data.

Confirm Order Edit Hook

Next, you'll add a hook that confirms the order edit. This hook will be used when the admin is done editing the quote's items. As mentioned earlier, confirming the order edit doesn't apply the changes to the order but finalizes the edit.

In src/admin/hooks/quotes.tsx, add the following hook:

src/admin/hooks/quotes.tsx
1export const useConfirmQuote = (2  id: string,3  options?: UseMutationOptions<4    HttpTypes.AdminOrderEditPreviewResponse,5    FetchError,6    void7  >8) => {9  const queryClient = useQueryClient()10
11  return useMutation({12    mutationFn: () => sdk.admin.orderEdit.request(id),13    onSuccess: (data: any, variables: any, context: any) => {14      queryClient.invalidateQueries({15        queryKey: [orderPreviewQueryKey, id],16      })17
18      options?.onSuccess?.(data, variables, context)19    },20    ...options,21  })22}

You create a useConfirmQuote hook that accepts the quote's ID and optional options as parameters. In the hook, you use the useMutation hook to define the mutation action that confirms the order edit using the sdk.admin.orderEdit.request method.

When the mutation is invoked, the hook invalidates the quote's data in the query client, which will trigger a re-fetch of the data.

Now that you have the necessary hooks, you can use them in the UI route and its components.

Add ManageItem Component#

The UI route will show the list of items to the admin user and allows them to update the item's quantity and price. So, you'll create a component that allows the admin to manage a single item's details. You'll later use this component for each item in the quote.

Screenshot of the manage item component in Medusa Admin

Create the file src/admin/components/manage-item.tsx with the following content:

Directory structure after adding the manage item component file

Code
1import { AdminOrder, AdminOrderPreview } from "@medusajs/framework/types"2import {3  Badge,4  CurrencyInput,5  Hint,6  Input,7  Label,8  Text,9  toast,10} from "@medusajs/ui"11import { useMemo } from "react"12import {13  useUpdateQuoteItem,14} from "../hooks/quotes"15import { Amount } from "./amount"16
17type ManageItemProps = {18  originalItem: AdminOrder["items"][0];19  item: AdminOrderPreview["items"][0];20  currencyCode: string;21  orderId: string;22};23
24export function ManageItem({25  originalItem,26  item,27  currencyCode,28  orderId,29}: ManageItemProps) {30  const { mutateAsync: updateItem } = useUpdateQuoteItem(orderId)31
32  const isItemUpdated = useMemo(33    () => !!item.actions?.find((a) => a.action === "ITEM_UPDATE"),34    [item]35  )36
37  const onUpdate = async ({38    quantity,39    unit_price,40  }: {41    quantity?: number;42    unit_price?: number;43  }) => {44    if (45      typeof quantity === "number" &&46      quantity <= item.detail.fulfilled_quantity47    ) {48      toast.error("Quantity should be greater than the fulfilled quantity")49      return50    }51
52    try {53      await updateItem({54        quantity,55        unit_price,56        itemId: item.id,57      })58    } catch (e) {59      toast.error((e as any).message)60    }61  }62  63  // TODO render the item's details and input fields64}

You define a ManageItem component that accepts the following props:

  • originalItem: The original item details from the quote. This is the item's details before any edits.
  • item: The item's details from the quote's order preview. This is the item's details which may have been edited.
  • currencyCode: The currency code of the quote's draft order.
  • orderId: The ID of the quote's draft order.

In the component, you define the following variables:

  • updateItem: The mutateAsync function returned by the useUpdateQuoteItem hook. This function updates the item's quantity and price using Medusa's Order Edits API routes.
  • isItemUpdated: A boolean that indicates whether the item has been updated.

You also define an onUpdate function that will be called when the admin updates the item's quantity or price. The function sends a request to update the item's quantity and price using the updateItem function. If the quantity is less than or equal to the fulfilled quantity, you show an error message.

Next, you'll add a return statement to show the item's details and allow the admin to update the item's quantity and price. Replace the TODO with the following:

src/admin/components/manage-item.tsx
1return (2  <div3    key={item.quantity}4    className="bg-ui-bg-subtle shadow-elevation-card-rest my-2 rounded-xl "5  >6    <div className="flex flex-col items-center gap-x-2 gap-y-2 p-3 text-sm md:flex-row">7      <div className="flex flex-1 items-center justify-between">8        <div className="flex flex-row items-center gap-x-3">9
10          <div className="flex flex-col">11            <div>12              <Text className="txt-small" as="span" weight="plus">13                {item.title}{" "}14              </Text>15
16              {item.variant_sku && <span>({item.variant_sku})</span>}17            </div>18            <Text as="div" className="text-ui-fg-subtle txt-small">19              {item.product_title}20            </Text>21          </div>22        </div>23
24        {isItemUpdated && (25            <Badge26              size="2xsmall"27              rounded="full"28              color="orange"29              className="mr-1"30            >31              Modified32            </Badge>33        )}34      </div>35
36      <div className="flex flex-1 justify-between">37        <div className="flex flex-grow items-center gap-2">38          <Input39            className="bg-ui-bg-base txt-small w-[67px] rounded-lg [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"40            type="number"41            disabled={item.detail.fulfilled_quantity === item.quantity}42            min={item.detail.fulfilled_quantity}43            defaultValue={item.quantity}44            onBlur={(e) => {45              const val = e.target.value46              const quantity = val === "" ? null : Number(val)47
48              if (quantity) {49                onUpdate({ quantity })50              }51            }}52          />53          <Text className="txt-small text-ui-fg-subtle">54            Quantity55          </Text>56        </div>57
58        <div className="text-ui-fg-subtle txt-small mr-2 flex flex-shrink-0">59          <Amount60            currencyCode={currencyCode}61            amount={item.total}62            originalAmount={originalItem?.total}63          />64        </div>65      </div>66    </div>67
68    <div className="grid grid-cols-1 gap-2 p-3 md:grid-cols-2">69      <div className="flex flex-col gap-y-1">70        <Label>Price</Label>71        <Hint className="!mt-1">72          Override the unit price of this product73        </Hint>74      </div>75
76      <div className="flex items-center gap-1">77        <div className="flex-grow">78          <CurrencyInput79            symbol={currencyCode}80              code={currencyCode}81            defaultValue={item.unit_price}82            type="numeric"83            min={0}84            onBlur={(e) => {85              onUpdate({86                unit_price: parseFloat(e.target.value),87                quantity: item.quantity,88              })89            }}90            className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover"91          />92        </div>93      </div>94    </div>95  </div>96)

You show the item's title, product title, and variant SKU. If the item has been updated, you show a "Modified" badge.

You also show input fields for the quantity and price of the item, allowing the admin to update the item's quantity and price. Once the admin updates the quantity or price, the onUpdate function is called to send a request to update the item's details.

Add ManageQuoteForm Component#

Next, you'll add the form component that shows the list of items in the quote and allows the admin to manage each item. You'll use the ManageItem component you created in the previous step for each item in the quote.

Screenshot of the manage quote form in the Medusa Admin

Create the file src/admin/components/manage-quote-form.tsx with the following content:

Directory structure after adding the manage quote form component file

src/admin/components/manage-quote-form.tsx
1import { AdminOrder } from "@medusajs/framework/types"2import { Button, Heading, toast } from "@medusajs/ui"3import { useConfirmQuote } from "../hooks/quotes"4import { formatAmount } from "../utils/format-amount"5import { useOrderPreview } from "../hooks/order-preview"6import { useNavigate, useParams } from "react-router-dom"7import { useMemo } from "react"8import { ManageItem } from "./manage-item"9
10type ReturnCreateFormProps = {11  order: AdminOrder;12};13
14export const ManageQuoteForm = ({ order }: ReturnCreateFormProps) => {15  const { order: preview } = useOrderPreview(order.id)16  const navigate = useNavigate()17  const { id: quoteId } = useParams()18
19  const { mutateAsync: confirmQuote, isPending: isRequesting } =20    useConfirmQuote(order.id)21
22  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {23    e.preventDefault()24    try {25      await confirmQuote()26      navigate(`/quotes/${quoteId}`)27
28      toast.success("Successfully updated quote")29    } catch (e) {30      toast.error("Error", {31        description: (e as any).message,32      })33    }34  }35  36    const originalItemsMap = useMemo(() => {37      return new Map(order.items.map((item) => [item.id, item]))38    }, [order])39
40  if (!preview) {41    return <></>42  }43
44  // TODO render form45}

You define a ManageQuoteForm component that accepts the quote's draft order as a prop. In the component, you retrieve the preview of that order. The preview holds any edits made on the order's items.

You also define the confirmQuote function using the useConfirmQuote hook. This function confirms the order edit, finalizing the changes made on the order's items.

Then, you define the handleSubmit function that will be called when the admin submits the form. The function confirms the order edit using the confirmQuote function and navigates the admin back to the quote's details page.

Next, you'll add a return statement to show the edit form for the quote's items. Replace the TODO with the following:

src/admin/components/manage-quote-form.tsx
1return (2  <form onSubmit={handleSubmit} className="flex h-full flex-col p-4 gap-2">3    <div>4      <div className="mb-3 mt-8 flex items-center justify-between">5        <Heading level="h2">Items</Heading>6      </div>7
8      {preview.items.map((item) => (9        <ManageItem10          key={item.id}11          originalItem={originalItemsMap.get(item.id)!}12          item={item}13          orderId={order.id}14          currencyCode={order.currency_code}15        />16      ))}17    </div>18
19    <div className="mt-8 border-y border-dotted py-4">20      <div className="mb-2 flex items-center justify-between">21        <span className="txt-small text-ui-fg-subtle">22          Current Total23        </span>24
25        <span className="txt-small text-ui-fg-subtle">26          {formatAmount(order.total, order.currency_code)}27        </span>28      </div>29
30      <div className="mb-2 flex items-center justify-between">31        <span className="txt-small text-ui-fg-subtle">32          New Total33        </span>34
35        <span className="txt-small text-ui-fg-subtle">36          {formatAmount(preview.total, order.currency_code)}37        </span>38      </div>39    </div>40
41    <div className="flex w-full items-center justify-end gap-x-4">42      <div className="flex items-center justify-end gap-x-2">43        <Button44          key="submit-button"45          type="submit"46          variant="primary"47          size="small"48          disabled={isRequesting}49        >50          Confirm Edit51        </Button>52      </div>53    </div>54  </form>55)

You use the ManageItem component to show each item in the quote and allow the admin to update the item's quantity and price. You also show the updated total amount of the quote and a button to confirm the order edit.

You'll use this component next in the UI route that allows the admin to edit the quote's items.

Implement UI Route#

Finally, you'll add the UI route that allows the admin to edit the quote's items. The route will use the ManageQuoteForm component you created in the previous step.

Create the file src/admin/routes/quotes/[id]/manage/page.tsx with the following content:

Directory structure after adding the edit quote items UI route file

src/admin/routes/quotes/[id]/manage/page.tsx
1import { useParams } from "react-router-dom"2import { useQuote } from "../../../../hooks/quotes"3import { Container, Heading, Toaster } from "@medusajs/ui"4import { ManageQuoteForm } from "../../../../components/manage-quote-form"5
6const QuoteManage = () => {7  const { id } = useParams()8  const { quote, isLoading } = useQuote(id!, {9    fields:10      "*draft_order.customer",11  })12
13  if (isLoading) {14    return <></>15  }16
17  if (!quote) {18    throw "quote not found"19  }20
21  return (22    <>23      <Container className="divide-y p-0">24        <Heading className="flex items-center justify-between px-6 py-4">25          Manage Quote26        </Heading>27
28        <ManageQuoteForm order={quote.draft_order} />29      </Container>30      <Toaster />31    </>32  )33}34
35export default QuoteManage

You define a QuoteManage component that will show the form to manage the quote's items in the Medusa Admin dashboard.

In the component, you first retrieve the quote's details using the useQuote hook. Then, you show the ManageQuoteForm component, passing the quote's draft order as a prop.

Add Manage Button to Quote Details Page#

To allow the admin to access the manage page you just added, you'll add a new button on the quote's details page that links to the manage page.

In src/admin/routes/quotes/[id]/page.tsx, add the following variable definition after the showSendQuote variable:

src/admin/routes/quotes/[id]/page.tsx
const [showManageQuote, setShowManageQuote] = useState(false)

This variable will be used to show or hide the manage quote button.

Then, update the existing useEffect hook to the following:

src/admin/routes/quotes/[id]/page.tsx
1useEffect(() => {2  if (["pending_merchant", "customer_rejected"].includes(quote?.status!)) {3    setShowSendQuote(true)4  } else {5    setShowSendQuote(false)6  }7
8  if (9    ["customer_rejected", "merchant_rejected", "accepted"].includes(10      quote?.status!11    )12  ) {13    setShowRejectQuote(false)14  } else {15    setShowRejectQuote(true)16  }17
18  if (![19    "pending_merchant",20    "customer_rejected",21    "merchant_rejected",22  ].includes(quote?.status!)) {23    setShowManageQuote(false)24  } else {25    setShowManageQuote(true)26  }27}, [quote])

The showManageQuote variable is now updated based on the quote's status, where you only show it if the quote is pending the merchant's action, or if it has been rejected by either the customer or merchant.

Finally, add the following button component after the Send Quote button:

src/admin/routes/quotes/[id]/page.tsx
1{showManageQuote && (2  <Button3    size="small"4    variant="secondary"5    onClick={() => navigate(`/quotes/${quote.id}/manage`)}6  >7    Manage Quote8  </Button>9)}

The Manage Quote button is now shown if the showManageQuote variable is true. When clicked, it navigates the admin to the manage quote page.

Test Edit Quote Items UI Route#

To test the edit quote items UI route, start the Medusa application:

Then, open the Medusa Admin dashboard at http://localhost:9000/admin. Open a quote's details page whose status is either pending_merchant, merchant_rejected or customer_rejected. You'll find a new "Manage Quote" button.

Manage Quote button on quote's details page in Medusa Admin

Click on the button, and you'll be taken to the manage quote page where you can update the quote's items. Try to update the items' quantities or price. Then, once you're done, click the "Confirm Edit" button to finalize the changes.

Edit quote items page in Medusa Admin

The changes can now be previewed from the quote's details page. The customer can also see these changes using the preview API route you created earlier. Once the customer accepts the quote, the changes will be applied to the order.


Next Steps#

You've now implemented quote management features in Medusa. There's still more that you can implement to enhance the quote management experience:

  • Refer to the B2B starter for more quote-management related features, including how to add or remove items from a quote, and how to allow messages between the customer and the merchant.
  • To build a storefront, refer to the Storefront development guide. You can also add to the storefront features related to quote-management using the APIs you implemented in this guide.

If you're new to Medusa, check out the main documentation, where you'll get a more in-depth learning of all the concepts you've used in this guide and more.

To learn more about the commerce features that Medusa provides, check out Medusa's Commerce Modules.

Was this page helpful?
Ask Anything
FAQ
What is Medusa?
How can I create a module?
How can I create a data model?
How do I create a workflow?
How can I extend a data model in the Product Module?
Recipes
How do I build a marketplace with Medusa?
How do I build digital products with Medusa?
How do I build subscription-based purchases with Medusa?
What other recipes are available in the Medusa documentation?
Chat is cleared on refresh
Line break