From 69468149445b25928f1628854b9ce54f7247f07c Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Sun, 9 Jun 2024 16:18:43 +0300 Subject: [PATCH] docs: update recipes to be based on v2 (#7516) --- www/apps/resources/app/_recipes/b2b/page.mdx | 375 --- .../app/_recipes/commerce-automation/page.mdx | 585 ----- .../resources/app/_recipes/ecommerce/page.mdx | 90 - .../integrate-ecommerce-stack/page.mdx | 297 --- .../app/_recipes/marketplace/page.mdx | 430 ---- .../app/_recipes/multi-region-store/page.mdx | 212 -- .../_recipes/personalized-products/page.mdx | 179 -- www/apps/resources/app/page.mdx | 49 +- www/apps/resources/app/recipes/b2b/page.mdx | 869 +++++++ .../app/recipes/commerce-automation/page.mdx | 1064 +++++++++ .../digital-products/page.mdx | 2043 ++++++++++------- .../resources/app/recipes/ecommerce/page.mdx | 79 + .../integrate-ecommerce-stack/page.mdx | 365 +++ .../app/recipes/marketplace/page.mdx | 1018 ++++++++ .../app/recipes/multi-region-store/page.mdx | 178 ++ .../omnichannel/page.mdx | 67 +- .../app/{_recipes => recipes}/oms/page.mdx | 132 +- .../recipes/personalized-products/page.mdx | 139 ++ .../app/{_recipes => recipes}/pos/page.mdx | 113 +- .../subscriptions/page.mdx | 115 +- www/apps/resources/generated/files-map.mjs | 96 +- www/apps/resources/generated/sidebar.mjs | 92 + www/apps/resources/sidebar.mjs | 55 + .../docs-ui/src/components/Card/index.tsx | 11 +- .../docs-ui/src/components/CardList/index.tsx | 6 +- 25 files changed, 5372 insertions(+), 3287 deletions(-) delete mode 100644 www/apps/resources/app/_recipes/b2b/page.mdx delete mode 100644 www/apps/resources/app/_recipes/commerce-automation/page.mdx delete mode 100644 www/apps/resources/app/_recipes/ecommerce/page.mdx delete mode 100644 www/apps/resources/app/_recipes/integrate-ecommerce-stack/page.mdx delete mode 100644 www/apps/resources/app/_recipes/marketplace/page.mdx delete mode 100644 www/apps/resources/app/_recipes/multi-region-store/page.mdx delete mode 100644 www/apps/resources/app/_recipes/personalized-products/page.mdx create mode 100644 www/apps/resources/app/recipes/b2b/page.mdx create mode 100644 www/apps/resources/app/recipes/commerce-automation/page.mdx rename www/apps/resources/app/{_recipes => recipes}/digital-products/page.mdx (51%) create mode 100644 www/apps/resources/app/recipes/ecommerce/page.mdx create mode 100644 www/apps/resources/app/recipes/integrate-ecommerce-stack/page.mdx create mode 100644 www/apps/resources/app/recipes/marketplace/page.mdx create mode 100644 www/apps/resources/app/recipes/multi-region-store/page.mdx rename www/apps/resources/app/{_recipes => recipes}/omnichannel/page.mdx (60%) rename www/apps/resources/app/{_recipes => recipes}/oms/page.mdx (61%) create mode 100644 www/apps/resources/app/recipes/personalized-products/page.mdx rename www/apps/resources/app/{_recipes => recipes}/pos/page.mdx (66%) rename www/apps/resources/app/{_recipes => recipes}/subscriptions/page.mdx (54%) diff --git a/www/apps/resources/app/_recipes/b2b/page.mdx b/www/apps/resources/app/_recipes/b2b/page.mdx deleted file mode 100644 index 651e543a05..0000000000 --- a/www/apps/resources/app/_recipes/b2b/page.mdx +++ /dev/null @@ -1,375 +0,0 @@ -import { AcademicCapSolid, UsersSolid } from "@medusajs/icons" -import { LearningPath } from "docs-ui" - -export const metadata = { - title: `B2B Recipe`, -} - -# {metadata.title} - -In this recipe, you'll find resources to guide you in building digital products with Medusa. - -## Overview - -In a B2B store, it’s essential to differentiate between customer types to provide them with relevant pricing, products, shopping experience, and more. - -Medusa’s commerce modules, including Sales Channel, Customer, and Pricing modules facilitate implementing this setup. Medusa’s architecture and extendible nature allow you to customize your store based on your use case. - - - -[Visionary: Frictionless B2B ecommerce with Medusa](https://medusajs.com/blog/visionary/) - - - - - ---- - -## Create B2B Sales Channel - -In Medusa, a sales channel allows you to set product availability per channel. In this case, create a B2B sales channel that includes only B2B products. - -Create a sales channel through the Medusa Admin or Admin REST APIs. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Option 2: Using the REST APIs", - text: "Create the sales channel using the REST APIs.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Create a Publishable API Key - -Publishable API keys are associated with one or more sales channels. Then, in a client such as a storefront, pass the publishable API key in the header of your requests to ensure all products retrieved belong to the associated sales channel(s). - -Create a publishable API key through the Medusa Admin or the Admin REST APIs, then associate it with the B2B sales channel. Later, use this key when developing your B2B storefront. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Option 2: Using the REST APIs", - text: "Create the publishable API key using the REST APIs.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Add Products - -To add B2B products, use the Medusa Admin or the Admin REST APIs. You can also import your products from an existing CSV file using the product-import feature. - -After or while you add your products, set the products’ availability to the B2B sales channel you’ve created. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Add Products Using REST APIs", - text: "Create the product using the REST APIs.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Import Products Using Medusa Admin", - text: "Import the products using the Medusa Admin.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Import Products Using REST APIs", - text: "Import the product using the REST APIs.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Create B2B Customer Groups - -In B2B sales, you often negotiate special prices with each customer or company. So, you organize your B2B customers into groups that allow you to set different prices for each. - -You can create a single B2B customer group that all your B2B customers fall into or create different B2B customer groups for every B2B customer. The second approach is helpful if you apply different prices for each business. - -While creating the customer groups, add to the `metadata` attribute of the customer group a key `is_b2b` that acts as a flag to indicate that this is a B2B customer group. It's useful for later steps. - -For more advanced customer modeling, you may create custom data models. This is covered in the [Create Custom Data Model section](#create-custom-data-model). - -, - showLinkIcon: false - }, - { - href: "#", - title: "Option 2: Using the REST APIs", - text: "Create the customer group using the REST APIs.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Add B2B Customers - -After adding your B2B customer group, add B2B customers and assign them to the B2B customer group. Alternatively, allowing B2B customers to register themselves is covered in a later step. - -Create a customer through the Medusa Admin or Admin API routes. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Option 2: Using the REST APIs", - text: "Create the customers using the REST APIs.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Create B2B Price List - -A price list allows you to set different prices on a set of products for various conditions. When building a B2B store, use this to assign different prices for B2B customer groups. - -Use price lists to set the same price for all B2B customers or different prices for different B2B customers if you’ve organized them into separate customer groups. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Add Price List Using REST APIs", - text: "Create the price list using the REST APIs.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Create Custom Data Model - -To implement a more advanced B2B sales flow, use custom data models such as `Company`, `Employee`, `Admin`, and `Buyer`. This provides more granular control of your B2B sales and allows you to build features like privileges, limits, and more. - -Each of the data models you create can be managed using custom logic. - -} - showLinkIcon={false} -/> - ---- - -## Create an API Route to Check Customers - -On the frontend clients communicating with your store, such as the storefront, you need to check whether the currently logged-in customer is a B2B customer. - -The API Route's logic differs based on how you’ve implemented the B2B logic. For example, if you’ve used the `is_b2b` flag in the customer group, your API Route checks whether that flag is enabled for the logged-in customer. - -} - showLinkIcon={false} -/> - -
- - For example, create the following API Route that allows you to check the customer’s group and whether it has the `is_b2b` flag enabled: - - ```ts title="src/api/store/customers/is-b2b/route.ts" - import type { - CustomerService, - MedusaRequest, - MedusaResponse, - } from "@medusajs/medusa" - - export const GET = async ( - req: MedusaRequest, - res: MedusaResponse - ) => { - const customerService: CustomerService = req.scope.resolve( - "customerService" - ) - - const customer = await customerService - .retrieve(req.user.customer_id, { - relations: ["groups"], - }) - - const is_b2b = customer.groups.some( - (group) => group.metadata.is_b2b === "true" - ) - - return res.json({ - is_b2b, - }) - } - ``` - - Then add the `requireCustomerAuthentication` middleware in `src/api/middlewares.ts` that ensures only authenticated customer can access this API Route: - - ```ts title="src/api/middlewares.ts" - import { - requireCustomerAuthentication, - type MiddlewaresConfig, - } from "@medusajs/medusa" - - export const config: MiddlewaresConfig = { - routes: [ - { - matcher: "/store/customers/is-b2b", - middlewares: [requireCustomerAuthentication()], - }, - ], - } - ``` - -
- ---- - -## Customize Admin - -Based on your use case, you may need to customize the Medusa Admin to add new widgets or pages. - -The Medusa Admin plugin can be extended to add widgets, new pages, and setting pages. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Create Admin UI Routes", - text: "Learn how to add new pages to your Medusa Admin.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Create Admin Setting Page", - text: "Learn how to add new page to the Medusa Admin settings.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Customize Storefront - -You may want different interfaces for B2B and regular customers on the storefront, or to display products differently for each type of customer. - -Medusa provides a Next.js Starter storefront that you can use and customize. You can also build your storefront with any frontend technology. Medusa provides you with different client libraries and REST APIs to use. - -Use the publishable API key you associated with your B2B sales channel in the storefront to ensure only B2B products are retrieved. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Options 2: Build Custom Storefront", - text: "Learn how to build a custom storefront with your preferred technology.", - startIcon: , - showLinkIcon: false - }, -]} /> - -} - showLinkIcon={false} - className="mt-1" -/> - ---- - -## Deploy B2B Store - -Once you finish your development, deploy your B2B Medusa application and storefront to your preferred hosting providers. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Deploy Storefront", - text: "Learn how to deploy your storefront.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Additional Development - -Refer to other guides in the Medusa Resources or the Medusa Book for additional guidance during your development. diff --git a/www/apps/resources/app/_recipes/commerce-automation/page.mdx b/www/apps/resources/app/_recipes/commerce-automation/page.mdx deleted file mode 100644 index 8bcb837f53..0000000000 --- a/www/apps/resources/app/_recipes/commerce-automation/page.mdx +++ /dev/null @@ -1,585 +0,0 @@ -import { AcademicCapSolid, BoltSolid } from "@medusajs/icons" -import { LearningPath } from "docs-ui" - -export const metadata = { - title: `Commerce Automation Recipe`, -} - -# {metadata.title} - -In this recipe, you'll learn how to implement different forms of commerce automation with Medusa. - -## Overview - -Commerce automation is essential for businesses to save costs, provide a better user experience, and avoid manual, repetitive tasks that lead to human errors. Businesses utilize automation in different domains, including marketing, customer support, and order management. - -Medusa provides the necessary architecture and tools to implement commerce automation for order management, customer service, and more. You can perform an asynchronous action when an event is triggered, schedule a job that runs at a specified interval, and more. - ---- - -## Re-Stock Notifications - -Customers may be interested in a product that is currently out of stock. - -Medusa provides a Re-stock notifications plugin. You can also implement this by creating custom entities, triggering custom events, and handling these events with a subscriber. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Create a Data Model", - text: "Learn how to create a custom data model.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Emit Events", - text: "Learn how to emit a custom event.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Create a Subscriber", - text: "Learn how to create a subscriber.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Automated Customer Support - -Customer support is essential to build a store's brand and customer loyalty. This can include integrating with third-party services or automating notifications sent to customers when changes happen related to their orders, returns, exchanges, and more. - -You can use Medusa's Notification Service to handle notifications triggered by actions on customer orders or profiles. - -For example, when an order's status is updated, the `order.updated` event is triggered. You can create a Notification Service that handles this event by emailing the customer about the status update. - -The Event reference includes an extensive list of events triggered by the Medusa core. - -Medusa also provides official notification plugins that integrate with third-party services, such as SendGrid or TwilioSMS. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Create Notification Service", - text: "Learn how to create a notification service.", - startIcon: , - showLinkIcon: false - }, -]} /> - -
- - Here’s an example of a subscriber that uses the SendGrid plugin to send an email to the customer when the order has a new note: - - ```ts title="src/subscribers/new-note.ts" - import { - type SubscriberConfig, - type SubscriberArgs, - ProductVariantService, - NoteService, - OrderService, - } from "@medusajs/medusa" - - export default async function handleNoteCreated({ - data, eventName, container, pluginOptions, - }: SubscriberArgs>) { - const noteService: NoteService = container.resolve( - "noteService" - ) - const orderService: OrderService = container.resolve( - "orderService" - ) - const sendgridService = container.resolve("sendgridService") - - // retrieve note by id - const note = await noteService.retrieve(data.id, { - relations: ["author"], - }) - - if (!note || note.resource_type !== "order") { - return - } - - // retrieve note's order - const order = await orderService.retrieve( - note.resource_id - ) - - if (!order) { - return - } - - sendGridService.sendEmail({ - templateId: "order-update", - from: "hello@medusajs.com", - to: order.email, - dynamic_template_data: { - // any data necessary for your template... - note_text: note.value, - note_author: note.author.first_name, - note_date: note.created_at, - order_id: order.display_id, - }, - }) - } - - export const config: SubscriberConfig = { - event: NoteService.Events.CREATED, - context: { - subscriberId: "note-created-handler", - }, - } - ``` - -
- ---- - -## Automatic Data Synchronization - -As your commerce store grows, you'll likely need to synchronize data across different systems. For example, you need to synchronize data with an ERP system or a data warehouse. - -Use scheduled jobs to implement automatic synchronization. Within the scheduled job, you can synchronize your internal or external data. - -} - showLinkIcon={false} -/> - -
- - Here’s an example of synchronizing products with a third party service using a loader: - - ```ts title="src/loaders/sync-products.ts" - import { - Logger, - ProductService, - StoreService, - MedusaContainer, - } from "@medusajs/medusa" - import { - ProductSelector, - } from "@medusajs/medusa/dist/types/product" - - export default async ( - container: MedusaContainer, - config: Record - ): Promise => { - const logger = container.resolve("logger") - logger.info("Synchronizing products...") - const productService = container.resolve( - "productService" - ) - const storeService = container.resolve( - "storeService" - ) - // retrieve store to get last sync date - const store = await storeService.retrieve() - - const productFilters: ProductSelector = {} - - if (store.metadata.last_sync_date) { - productFilters.updated_at = { - gt: new Date( - store.metadata.last_sync_date as string - ), - } - } - - const updatedProducts = await productService.list( - productFilters - ) - - updatedProducts.forEach((product) => { - // assuming client is an initialized connection - // with a third-party service - client.sync(product) - }) - - await storeService.update({ - metadata: { - last_sync_date: new Date(), - }, - }) - - logger.info("Finish synchronizing products") - } - ``` - - Notice that here it’s assumed that: - - 1. The last update date is stored in the `Store`'s metadata object. You can instead use a custom data model to handle this. - 2. The connection to the third-party service is assumed to be available and handled within the `client` variable. - -
- ---- - -## Order Management Automation - -Using Medusa's architecture and commerce features, you can automate a large amount of order management functionalities. - -To handle events within an order flow and automate actions, create a subscriber. For example, create a subscriber that listens to the `order.placed` event and automatically creates a fulfillment if predefined conditions are met. - -Another example is creating a subscriber that listens to the `order.shipment_created` event and automatically captures payment when an order is fully shipped. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Create a Subscriber", - text: "Learn how to create a subscriber to listen to events.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Events Reference", - text: "Check out triggered events in Medusa and their expected payloads.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Automated RMA Flow - -Businesses must optimize their Return Merchandise Authorization (RMA) flow to ensure a good customer experience and service. By automating the flow, customers request to return their received items, and businesses quickly support them. - -Medusa's commerce features are geared towards automating RMA flows and ensuring a good customer experience. - -For example, customers can create returns for their orders without direct involvement from the store operator. The store operator then receives a notification regarding the return and handles it accordingly. The same applies to order exchanges. - -Medusa also provides features that allow a store operator to edit orders, receive customer approval for the edits, and authorize additional payment if necessary, all within a seamless flow. - -Similar to the examples mentioned earlier, each action triggers events you can listen to and perform additional actions based on your use case. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Create a Subscriber", - text: "Learn how to create a subscriber.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Events Reference", - text: "Check out triggered events in Medusa.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Order Returns", - text: "Learn about the Order Returns features.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Order Swaps", - text: "Learn about the Order Swaps features.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Order Edits", - text: "Learn about the Order Edits features.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Customer Segmentation - -Businesses use customer segmentation to organize customers into different groups and then apply different price rules to these groups. For example, you may group customers by their product preferences, the number of orders they've placed, or geographical location. - -Based on your use case and the segmentation logic, you can use different Medusa automation development tools to detect a customer's segment. Medusa also provides a customer groups feature that allows you to segment customers, whether you do it manually or automatically. - -For example, to group customers with over twenty orders, create a subscriber that listens to the `order.placed` event and checks the number of orders the customer has so far. Then, add that customer to the VIP customer group. Finally, you can use the price lists feature to provide different prices or discounts for customers in the VIP group. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Subscribers and Events", - text: "Learn about events in Medusa and how to listen to events.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Price Lists", - text: "Learn about the Price Lists architecture and features.", - startIcon: , - showLinkIcon: false - }, -]} /> - -
- - Here’s an example of a subscriber that listens to the `order.placed` event and checks if the customer should be added to the VIP customer group based on their number of orders: - - ```ts title="src/subscribers/add-custom-to-vip.ts" - import { - type SubscriberConfig, - type SubscriberArgs, - OrderService, - CustomerService, - CustomerGroupService, - } from "@medusajs/medusa" - - export default async function handleOrderPlaced({ - data, eventName, container, pluginOptions, - }: SubscriberArgs>) { - const orderService: OrderService = container.resolve( - "orderService" - ) - const customerService: CustomerService = container.resolve( - "customerService" - ) - const customerGroupService: CustomerGroupService = - container.resolve("customerGroupService") - - // check if VIP group exists - const vipGroup = await customerGroupService.list({ - name: "VIP", - }, { - relations: ["customers"], - }) - if (!vipGroup.length) { - return - } - - // retrieve order and its customer - const order = await orderService.retrieve(data.id) - - if (!order || !order.customer_id || - vipGroup[0].customers.find( - (customer) => customer.id === order.customer_id - ) !== undefined) { - return - } - - // retrieve orders of this customer - const [, count] = await orderService.listAndCount({ - customer_id: order.customer_id, - }) - - if (count >= 20) { - // add customer to VIP group - customerGroupService.addCustomers( - vipGroup[0].id, - order.customer_id - ) - } - } - - export const config: SubscriberConfig = { - event: OrderService.Events.PLACED, - context: { - subscriberId: "order-placed-handler", - }, - } - ``` - -
- - ---- - -## Marketing Automation - -In your commerce store, you may utilize marketing strategies that encourage customers to make purchases. For example, you send a newsletter when new products are added to your store. - -To do that, create a subscriber that listens to the `product.created`, and send an email to subscribed customers with tools like SendGrid or Mailchimp. - -Alternatively, create a scheduled job that checks whether the number of new products has exceeded a set threshold, then sends out the newsletter. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Scheduled Jobs", - text: "Learn about scheduled jobs and how you can create one.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Events Reference", - text: "Check out triggered events in Medusa and their expected payloads.", - startIcon: , - showLinkIcon: false - }, -]} /> - -
- - Here’s an example of listening to the `product.created` event in a subscriber and send a newsletter if the condition is met: - - ```ts title="src/subscribers/send-products-newsletter.ts" - import { - type SubscriberConfig, - type SubscriberArgs, - ProductService, - StoreService, - CustomerService, - } from "@medusajs/medusa" - import { - ProductSelector, - } from "@medusajs/medusa/dist/types/product" - - export default async function handleOrderPlaced({ - data, eventName, container, pluginOptions, - }: SubscriberArgs>) { - const productService: ProductService = container.resolve( - "productService" - ) - const storeService: StoreService = container.resolve( - "storeService" - ) - const customerService: CustomerService = container.resolve( - "customerService" - ) - const sendgridService = container.resolve("sendgridService") - - // retrieve store to have access to last send date - const store = await storeService.retrieve() - - const productFilters: ProductSelector = {} - if (store.metadata.last_send_date) { - productFilters.created_at = { - gt: new Date(store.metadata.last_send_date as string), - } - } - - const products = await productService.list( - productFilters - ) - - if (products.length > 10) { - // get subscribed customers - const customers = await customerService.list({ - metadata: { - is_subscribed: true, - }, - }) - sendgridService.sendEmail({ - templateId: "product-newsletter", - from: "hello@medusajs.com", - to: customers.map((customer) => ({ - name: customer.first_name, - email: customer.email, - })), - dynamic_template_data: { - // any data necessary for your template... - products, - }, - }) - - await storeService.update({ - metadata: { - last_send_date: new Date(), - }, - }) - } - } - - export const config: SubscriberConfig = { - event: ProductService.Events.CREATED, - context: { - subscriberId: "product-create-handler", - }, - } - ``` - -
- ---- - -## Automation Development Toolkit - -The use cases mentioned in this guide exemplify what commerce automation you can perform or implement with Medusa. Medusa provides the necessary development tools and mechanisms to automate tasks and manual work. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Notification Service", - text: "Learn about notification services and how to create one.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Scheduled Jobs", - text: "Learn about scheduled jobs and how you can create one.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Notification Plugins", - text: "Check out available notification plugins.", - startIcon: , - showLinkIcon: false - }, -]} /> diff --git a/www/apps/resources/app/_recipes/ecommerce/page.mdx b/www/apps/resources/app/_recipes/ecommerce/page.mdx deleted file mode 100644 index e98e884ead..0000000000 --- a/www/apps/resources/app/_recipes/ecommerce/page.mdx +++ /dev/null @@ -1,90 +0,0 @@ -import { AcademicCapSolid, UsersSolid } from "@medusajs/icons" -import { LearningPath } from "docs-ui" - -export const metadata = { - title: `Ecommerce Recipe`, -} - -# {metadata.title} - -In this recipe, you'll learn how to build an ecommerce store and start selling with Medusa. - -## Overview - -Businesses use an ecommerce store to allow customers to browse their products and make purchases. It also includes accepting payments, managing orders, and more. - -Medusa provides all essential commerce features out-of-the-box. Businesses can go live and start selling without making any adjustments. They can also power-up their store with plugins for payment, fulfillment, and more. - - - -[How Tekla created an ecommerce store using Medusa](https://medusajs.com/blog/tekla/). - - - - - ---- - -## Create a Next.js Starter Storefront - -The Medusa application can be used with any storefront. All you have to do is connect to its Store APIs to use the application's commerce features. - -You can install, use, and customize the Next.js Starter storefront to benefit from all the necessary ecommerce features. Alternatively, you can build your own storefront. - -} - showLinkIcon={false} -/> - ---- - -## (Optional) Install Plugins - -Medusa provides official plugins for payment, fulfillment, search, and more. These plugins provide new functionalities for both your Medusa application and your storefront. - -For example, to accept payment with Stripe, install the Stripe plugin. - -} - showLinkIcon={false} -/> - ---- - -## Deploy the Medusa Application - -The first step to go live is to deploy the Medusa application. The deployment guides available in our Resources documentation provide extensive step-by-step guide for different hosting providers, as well as a general deployment guide that you can follow to deploy your Medusa application to other providers. - -} - showLinkIcon={false} -/> - ---- - -## Deploy the Storefront - -You can now deploy your storefront. The instructions defer whether you're using the Next.js Starter storefront, a custom storefront, or other forms of storefronts such as a mobile app. - -} - showLinkIcon={false} -/> - ---- - -## Additional Development - -Refer to other guides in the Medusa Resources or the Medusa Book for additional guidance during your development. diff --git a/www/apps/resources/app/_recipes/integrate-ecommerce-stack/page.mdx b/www/apps/resources/app/_recipes/integrate-ecommerce-stack/page.mdx deleted file mode 100644 index 1f3a9bd590..0000000000 --- a/www/apps/resources/app/_recipes/integrate-ecommerce-stack/page.mdx +++ /dev/null @@ -1,297 +0,0 @@ -import { AcademicCapSolid } from "@medusajs/icons" -import { LearningPath } from "docs-ui" - -export const metadata = { - title: `Integrate Ecommerce Stack Recipe`, -} - -# {metadata.title} - -In this recipe, you'll learn how to integrate your systems in your ecommerce stack with Medusa. - -## Overview - -Integrating third-party systems, such as ERP or a CMS, into your ecommerce stack can be challenging. It requires: - -- Establishing connections with the different systems based on each of their APIs. -- Building flows that span across multiple systems. -- Maintaining data consistency and syncing between your systems. - -Medusa’s architecture and functionalities allow you to integrate third-party systems and build flows around them. It also provides error-handling mechanisms and webhook capabilities that prevent data inconsistency between your systems. - - - ---- - -## Connect to External Systems with Services - -Medusa’s Services let you implement a client that connects and performs functionalities with your third-party system. You can then use the service to connect to your third-party system in other resources, such as a Workflow or an API Route. - -This increases the maintainability of your integration as it’s all implemented within the service. If the external system makes any changes to its APIs, you only need to make changes within the service. - -} - showLinkIcon={false} -/> - -
- - Here’s an example of a service created for an external ERP system: - - ```ts title="src/services/erp.ts" - import { ProductService, TransactionBaseService } from "@medusajs/medusa"; - import { MedusaError } from "@medusajs/utils" - import axios, { AxiosInstance } from "axios" - - type InjectedDependencies = { - productService: ProductService - } - - class ErpService extends TransactionBaseService { - private client_: AxiosInstance - private productService_: ProductService - - constructor(container: InjectedDependencies) { - super(container) - - this.productService_ = container.productService - this.client_ = axios.create({ - baseURL: `https://api.erp-example.com`, - headers: { - Authorization: `Bearer ${process.env.ERP_TOKEN}` - } - }) - } - - async getProductERPDetails (id: string) { - const product = await this.productService_.retrieve(id, { - select: ["external_id"] - }) - - if (!product) { - throw new MedusaError(MedusaError.Types.NOT_FOUND, `Product with id ${id} was not found.`) - } - - const erpProduct = await this.client_.get(`/product/${product.external_id}`) - - return erpProduct - } - - async updateProduct(product: any) { - // do stuff - } - } - - export default ErpService - ``` - - In this example, you create an `ErpService` that is used to interact with the third-party system. In the service, you create a client using [axios](https://axios-http.com/) to send requests to the ERP system. If the system you’re integrating provides a JavaScript SDK, you can use that instead. - - Then, in the service, you create methods for each ERP functionality. For example, the `getProductErpDetails` method retrieves the details of a product in the ERP system. - - - - Products have an `external_id` attribute that can be used to store the product's ID in an external system. - - - -
- - ---- - -## Build Flows Across Systems - -With Medusa’s workflows, you can build flows with steps that may perform actions on different systems. For example, you can create a workflow that updates the product’s details in integrated systems like ERPs, WMSs, and CMSs. - -Workflows can be executed from anywhere. So, taking the workflow described in the above example, you can listen to the `product.updated` event using a Subscriber and execute the workflow whenever the event is triggered. - -![A flowchart of how the workflow is executed when the product.updated event is triggered](https://res.cloudinary.com/dza7lstvk/image/upload/v1709025778/Medusa%20Book/workflow-recipe-example_yfwfuy.jpg) - -Workflows guarantee data consistency through their compensation feature. You can provide a compensation function to steps that roll back the actions of that step. Then, if an error occurs in any step, the actions of previous steps are rolled back using their compensation function. - -} - showLinkIcon={false} -/> - -
- - For example, create the following workflow in `src/workflows/update-product.ts`: - - ```ts title="src/workflows/update-product.ts" - import { Product, ProductService } from "@medusajs/medusa" - import { - createStep, - StepResponse, - createWorkflow - } from "@medusajs/workflows-sdk" - import ErpService from "../services/erp" - - type WorkflowInput = { - productId: string - } - - const updateInErpStep = createStep("update-in-erp", async ({ productId }: WorkflowInput, context) => { - const erpService: ErpService = context.container.resolve("erpService") - const productService: ProductService = context.container.resolve("productService") - - const updatedProductData = await productService.retrieve(productId) - - const oldProductData = await erpService.getProductERPDetails(productId) - - await erpService.updateProduct(updatedProductData) - - return new StepResponse({}, { - // provide the old product data as a parameter - // to the compensation function in case an error - // occurs. - oldProductData - }) - }, async ({ - oldProductData - }, context) => { - const erpService: ErpService = context.container.resolve("erpService") - - await erpService.updateProduct(oldProductData) - }) - - const updateInCmsStep = createStep("update-in-cms", async({ productId }: WorkflowInput, context) => { - // update product in CMS... - }) - - const updateInWmsStep = createStep("update-in-wms", async({ productId }: WorkflowInput, context) => { - // update product in WMS... - }) - - const updateProductWorkflow = createWorkflow< - WorkflowInput, void - >("update-product-in-systems", - function (input) { - updateInErpStep(input) - - updateInCmsStep(input) - - updateInWmsStep(input) - }) - - export default updateProductWorkflow - ``` - - In this workflow, you create three steps: one to update the product in an ERP, another in a CMS, and another in a WMS. - - In the `update-in-erp` step, you resolve the ERP’s service that you created and use it to update the product in the ERP. You also provide a compensation function, which is passed as a third parameter to the `createStep` function. The compensation function reverts the update in the ERP system in case an error occurs. - - You then create the workflow `update-product-in-systems` which uses the three steps in its constructor function. The workflow’s constructor function doesn’t run until it’s executed. - - Then, create the subscriber at `src/subscribers/update-product.ts`: - - ```ts title="src/subscribers/update-product.ts" - import { - type SubscriberConfig, - type SubscriberArgs, - ProductService, - } from "@medusajs/medusa" - import updateProductWorkflow from "../workflows/update-product" - - export default async function handleProductUpdate({ - data, eventName, container, pluginOptions - }: SubscriberArgs>) { - updateProductWorkflow(container) - .run({ - input: { - productId: data.id - } - }) - .then(() => { - console.log("Updated product across systems.") - }) - } - - export const config: SubscriberConfig = { - event: ProductService.Events.UPDATED, - context: { - subscriberId: "product-update" - } - } - ``` - - The subscriber executes the workflow whenever the `product.updated` event is triggered, passing it the ID of the updated product. - -
- ---- - -## Create Webhook Listeners - -You can provide webhook listeners that your external systems call when their data is updated. This lets you synchronize data between your systems. To create webhook listeners, create an API route. - -For example, suppose an administrator changes the product data in the ERP system. The system then sends a request to the webhook you define in your Medusa application, which updates the product data in the application. - -} - showLinkIcon={false} -/> - -
- - For example, create the file `src/api/webhooks/erp/update/route.ts` with the following content: - - ```ts title="src/api/webhooks/erp/update/route.ts" - import { - MedusaRequest, - MedusaResponse, - ProductService - } from "@medusajs/medusa" - - export const POST = async (req: MedusaRequest, res: MedusaResponse) => { - const { id, updatedData} = req.body - - const productService: ProductService = req.scope.resolve( - "productService" - ) - - await productService.update(id, updatedData) - - res.status(200) - } - ``` - - This creates a webhook listener for an ERP system. It receives the ID of a product and its updated data, assuming that’s how your ERP system sends the data. - - Then, create the file `src/api/middlewares.ts` with the following content: - - ```ts title="src/api/middlewares.ts" - import { MiddlewaresConfig } from "@medusajs/medusa" - import { raw } from "body-parser" - - export const config: MiddlewaresConfig = { - routes: [ - { - method: ["POST", "PUT"], - matcher: "/webhooks/*", - bodyParser: false, - middlewares: [raw({ type: "application/json" })], - }, - ], - } - ``` - - This replaces the default JSON middleware with the raw middleware, which is useful for webhook routes. - -
- ---- - -## Additional Development - -Refer to other guides in the Medusa Resources or the Medusa Book for additional guidance during your development. diff --git a/www/apps/resources/app/_recipes/marketplace/page.mdx b/www/apps/resources/app/_recipes/marketplace/page.mdx deleted file mode 100644 index 2b98cf03f5..0000000000 --- a/www/apps/resources/app/_recipes/marketplace/page.mdx +++ /dev/null @@ -1,430 +0,0 @@ -import { AcademicCapSolid, UsersSolid } from "@medusajs/icons" -import { LearningPath } from "docs-ui" - -export const metadata = { - title: `Marketplace Recipe`, -} - -# {metadata.title} - -In this recipe, you'll find resources to guide you in building a marketplace with Medusa. - -## Overview - -A marketplace is an online commerce store that allows different vendors to sell their products within the same commerce system. Customers can purchase products from any of these vendors, and vendors can manage their orders separately. - - - -[How Goodchef built subscription-based purchases with Medusa](https://medusajs.com/blog/foraged/). - - - - - ---- - -## Associate Data Models with Stores - -By default, data models like users, products, or orders aren't associated with a store, as it's assumed there's one store in Medusa. For a marketplace, each of these data models should be associated with their respective stores. - -To associate these data models with the `Store` data model, extend and customize data models created in the Medusa core package `@medusajs/medusa`, such as the `User` data model, to add a relation to the `Store` data model. - -} - showLinkIcon={false} -/> - -
- - For example, to associate the `User` data model with the `Store` data model, create the file `src/models/user.ts` with the following content: - - ```ts title="src/models/user.ts" - import { - Column, - Entity, - Index, - JoinColumn, - ManyToOne, - } from "typeorm" - import { - User as MedusaUser, - } from "@medusajs/medusa" - import { Store } from "./store" - - @Entity() - export class User extends MedusaUser { - @Index("UserStoreId") - @Column({ nullable: true }) - store_id?: string - - @ManyToOne(() => Store, (store) => store.members) - @JoinColumn({ name: "store_id", referencedColumnName: "id" }) - store?: Store - } - ``` - - Then, you need to extend the `UserRepository` to point to your extended data model. To do that, create the file `src/repositories/user.ts` with the following content: - - ```ts title="src/repositories/user.ts" - import { User } from "../models/user" - import { - dataSource, - } from "@medusajs/medusa/dist/loaders/database" - import { - UserRepository as MedusaUserRepository, - } from "@medusajs/medusa/dist/repositories/user" - - export const UserRepository = dataSource - .getRepository(User) - .extend({ - ...Object.assign( - MedusaUserRepository, - { target: User } - ), - }) - - export default UserRepository - ``` - - Next, you need to create a migration that reflects the changes on the `User` data model in your database. To do that, run the following command to create a migration file: - - ```bash - npx typeorm migration:create src/migrations/add-user-store-id - ``` - - This creates a file in the `src/migrations` directory of the format `_add-user-store-id.ts`. Replace the `up` and `down` methods in that file with the methods here: - - ```ts title="src/migrations/_add-user-store-id.ts" - // ... - - export class AddUserStoreId1681287255173 - implements MigrationInterface { - // ... - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `ALTER TABLE "user" ADD "store_id" character varying` - ) - await queryRunner.query( - `CREATE INDEX "UserStoreId" ON "user" ("store_id")` - ) - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `DROP INDEX "public"."UserStoreId"` - ) - await queryRunner.query( - `ALTER TABLE "user" DROP COLUMN "store_id"` - ) - } - - } - ``` - - Finally, to reflect these changes and start using them, `build` your changes and run migrations with the following commands: - - ```bash npm2yarn - npm run build - npx medusa migrations run - ``` - - You can extend other data models in a similar manner to associate them with a store. - -
- ---- - -## Access Logged-in User - -Throughout your development, you'll likely need access to the logged-in user. For example, you add a new product to the logged-in admin user's store. - -} - showLinkIcon={false} -/> - ---- - -## Customize Data Management Functionalities - -After associating data models with stores, customize how certain data management functionalities are implemented in the Medusa core package. - -For example, when a new user is created, you ensure that it's associated either with a new store or with the store of the logged-in user. Another example is associating a new product with the logged-in user's store. - -To customize these functionalities, extend services. - -} - showLinkIcon={false} -/> - -
- - You can extend the user service to change how the `create` method is implemented. - - To extend the user service, create the file `src/services/user.ts` with the following content: - - ```ts title="src/services/user.ts" - import { Lifetime } from "awilix" - import { - UserService as MedusaUserService, - } from "@medusajs/medusa" - import { User } from "../models/user" - import { - CreateUserInput as MedusaCreateUserInput, - } from "@medusajs/medusa/dist/types/user" - import StoreRepository from "../repositories/store" - - type CreateUserInput = { - store_id?: string - } & MedusaCreateUserInput - - class UserService extends MedusaUserService { - static LIFE_TIME = Lifetime.SCOPED - protected readonly loggedInUser_: User | null - protected readonly storeRepository_: typeof StoreRepository - - constructor(container, options) { - super(...arguments) - this.storeRepository_ = container.storeRepository - - try { - this.loggedInUser_ = container.loggedInUser - } catch (e) { - // avoid errors when backend first runs - } - } - - async create( - user: CreateUserInput, - password: string - ): Promise { - if (!user.store_id) { - const storeRepo = this.manager_.withRepository( - this.storeRepository_ - ) - let newStore = storeRepo.create() - newStore = await storeRepo.save(newStore) - user.store_id = newStore.id - } - - return await super.create(user, password) - } - } - - export default UserService - ``` - - In the `create` method of this extended service, you create a new store if the user being created doesn't have a store associated with it. - - You can then test out your customization by running the `build` command and starting the Medusa application: - - ```bash npm2yarn - npm run build - npx medusa develop - ``` - -
- ---- - -## Listening to Events - -While implementing your marketplace, you'll need to listen to certain events then perform actions asynchronously. For example, you listen to the `order.placed` event and, when triggered, create child orders of the order, separating ordered items by their associated store. - -To listen to events, create Subscribers that subscribe a handler function to an event. In that handler function, implement the desired functionality. - -} - showLinkIcon={false} -/> - -
- - To listen to the `order.placed` event, create the file `src/subscribers/orderNotifier.ts` with the following content: - - ```ts title="src/subscribers/orderNotifier.ts" - import { - type SubscriberConfig, - type SubscriberArgs, - OrderService, - } from "@medusajs/medusa" - - export default async function handleOrderPlaced({ - data, eventName, container, pluginOptions, - }: SubscriberArgs>) { - // TODO perform functionality - } - - export const config: SubscriberConfig = { - event: OrderService.Events.PLACED, - context: { - subscriberId: "order-placed-handler", - }, - } - ``` - - This subscribes the `handleOrder` method to be executed whenever the `order.placed` event is emitted. - - You can then test out your subscriber by running the `build` command and starting the Medusa application: - - ```bash npm2yarn - npm run build - npx medusa develop - ``` - -
- ---- - -## Add Payment and Fulfillment Providers - -Payment and fulfillment providers can be added through plugins or directly in your project. You can either create your own provider, use one of Medusa's official plugins, or use community plugins. - - - -Payment and fulfillment providers are associated with regions, which are not associated with a store, by default. To allow each store to specify its own payment and fulfillment providers, [associate the region with a store](#associate-data-models-with-stores). - - - -### Option 1: Create your own providers - -, - showLinkIcon: false - }, - { - href: "#", - title: "Create a Fulfillment Provider", - text: "Learn how to create a fulfillment provider.", - startIcon: , - showLinkIcon: false - } -]} /> - -### Option 2: Install a Plugin - -, - showLinkIcon: false - }, - { - href: "https://medusajs.com/plugins/", - title: "Install a Community Plugin", - text: "Check out available community plugins to install.", - startIcon: , - showLinkIcon: false - } -]} /> - ---- - -## Customize Admin - -You can customize the admin to provide an interface to manage your custom features. - -You can extend the Medusa Admin plugin to add widgets to existing pages, add new pages, or add setting pages. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Admin UI Routes", - text: "Learn how to add new pages to your Medusa Admin.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Admin Setting Page", - text: "Learn how to add new page to the Medusa Admin settings.", - startIcon: , - showLinkIcon: false - } -]} /> - ---- - -## Implement Role-Based Access Control - -In your marketplace, you may need to implement role-based access control (RBAC) within stores. This restricts some users' permissions to specified functionalities or API Routes. - -To implement this functionality, follow the RBAC recipe. - -} - showLinkIcon={false} -/> - ---- - -## Build a Storefront - -Medusa provides a Next.js Starter. Since you've customized your Medusa project, you must either customize the existing Next.js Starter, or create a custom storefront. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Option 2: Custom Storefront", - text: "Find resources to build a storefront.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Deploy Marketplace - -The Medusa Resources documentation includes deployment guides for a basic Medusa application. You can follow it to deploy your customized marketplace, as well. - -} - showLinkIcon={false} -/> - ---- - -## Additional Development - -Refer to other guides in the Medusa Resources or the Medusa Book for additional guidance during your development. diff --git a/www/apps/resources/app/_recipes/multi-region-store/page.mdx b/www/apps/resources/app/_recipes/multi-region-store/page.mdx deleted file mode 100644 index 7f4ccf518c..0000000000 --- a/www/apps/resources/app/_recipes/multi-region-store/page.mdx +++ /dev/null @@ -1,212 +0,0 @@ -import { AcademicCapSolid, UsersSolid } from "@medusajs/icons" -import { LearningPath } from "docs-ui" - -export const metadata = { - title: `Multi-Region Store Recipe`, -} - -# {metadata.title} - -In this recipe, you'll find resources to guide you in building a multi-region store with Medusa. - -## Overview - -A multi-regional store allows merchants to sell across different countries. This includes supporting each country's tax rules, currency, available shipping and payment options, and more. - -Medusa comes with multi-regional support out of the box. This recipe explains how to benefit from Medusa's features to create a multi-regional store. - ---- - -## Multi-Region Setup - -With Medusa, you can create unlimited regions in your store. Each region has configurations managed through the Medusa Admin Dashboard or the Admin API. - -### Currency - -Merchants specify the currency of each region. Multiple regions can have the same currency, but a region has only one currency. - -When customers view your products from a region, they see the prices in the region’s currency. As explained later, this also affects which prices are shown to the customer in a region. - -### Tax rates and providers - -At the regional level, merchants define a default tax rate and tax rates for specific conditions (for example, for a particular set of products). Each region also has a different tax provider that calculates the taxes. You can use Medusa's default tax provider or create a custom tax provider. - -During checkout, the taxes are calculated based on the customer's region, tax configurations, and rates. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Manage Taxes Using Admin APIs", - text: "Learn how to manage tax settings using the Admin APIs.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Create Tax Provider", - text: "Learn how to create a tax provider.", - startIcon: , - showLinkIcon: false - }, -]} /> - -### Payment and Fulfillment Providers - -Merchants choose which payment and fulfillment providers are available in each region. For example, one region can use Payment Provider A and B while another only uses Payment Provider B. - -During checkout, customers only see the payment providers and shipping options configured for the region. This lets you give customers a localized experience that feels familiar and instills trust. - -Medusa provides official plugins for payment and fulfillment providers. You can also create custom providers. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Available Payment Plugins", - text: "Check out available payment plugins.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Create Payment Provider", - text: "Learn how to create a payment provider.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Create Fulfillment Provider", - text: "Learn how to create a fulfillment provider.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Prices Per Region and Currency - -When a merchant sets the price of a product in Medusa, they specify the price per currency and region. Merchants also benefit from this pricing mechanism when adding sales or overriding prices for specific conditions using the Pricing Module's features. - -Then, customers see the price of a product based on the region they're in, the region's currency, and whether any special pricing applies to their case. Medusa has a pricing strategy to show the customer the best price based on their context. - -Using the tax-inclusive feature, merchants specify prices including taxes per currency and region. Medusa then calculates the tax amount applied to a line item in the cart based on the region's tax configurations. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Display Product Price in Storefront", - text: "Learn how to display the correct product price in a storefront.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Multi-Warehouse Support - -Medusa's Inventory and Stock Location Modules provide multi-warehouse features that allow you to manage inventory across different locations. Merchants then control which location an item in an order is fulfilled, allowing them to keep a correct inventory count across locations and sales channels. - -A multi-regional setup lets you manage your inventory through Medusa across the different regions you serve. Your products’ inventory and availability aren't bound to a single stock location, and customers are always shown accurate inventory information based on the location associated with their sales channel. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Manage Stock Locations", - text: "Learn how to manage stock locations in the Medusa Admin.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Manage Inventory", - text: "Learn how to manage inventory in the Medusa Admin.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Multi-Lingual Setup - -By integrating a third-party Content Management Systems (CMS), you benefit from rich content features including managing your content in multiple languages. This allows you to cater to customers’ different languages in the regions you serve. - -Medusa provides an official Contentful plugin and a community Strapi plugin. You can also create a CMS plugin that integrates with your preferred CMS platform to provide multi-lingual features. - -, - showLinkIcon: false - }, - { - href: "#", - title: "How to Create a Plugin", - text: "Learn how to create a plugin.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Automatic Location Detection - -When you sell across regions, it's essential to detect the customer's region and provide them with the products, prices, and content relevant to them without requiring actions from their side. - -Medusa provides an IP Lookup plugin that lets you detect a customer's location and region using [ipstack](https://ipstack.com/). You can also create a plugin that detects the location through a different logic. - -, - showLinkIcon: false - }, - { - href: "#", - title: "How to Create a Plugin", - text: "Learn how to create a plugin.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Additional Development - -Refer to other guides in the Medusa Resources or the Medusa Book for additional guidance during your development. diff --git a/www/apps/resources/app/_recipes/personalized-products/page.mdx b/www/apps/resources/app/_recipes/personalized-products/page.mdx deleted file mode 100644 index f690a7aed2..0000000000 --- a/www/apps/resources/app/_recipes/personalized-products/page.mdx +++ /dev/null @@ -1,179 +0,0 @@ -import { AcademicCapSolid, NextJs } from "@medusajs/icons" -import { LearningPath } from "docs-ui" - -export const metadata = { - title: `Personalized Products Recipe`, -} - -# {metadata.title} - -In this recipe, you'll find resources to guide you in building digital products with Medusa. - -## Overview - -Personalized products are products that customers can customize based on their need. For example, they can upload an image to print on a shirt or provide a message to include in a letter. - -Medusa’s modular architecture and customization tools allows you to create custom data models and expose new features through the REST APIs. You also have freedom in how you choose to implement the storefront, allowing you to build a unique experience around your products. - ---- - -## Store Personalized Data - -Most of the data models in Medusa’s commerce modules include a `metadata` attribute, which can be used to store custom data as key-value pairs. - -The `Product` data model represents the main product, whereas the `ProductVariant` data model represents the different saleable options of that product. For example, a shirt is a `Product`, and each different color of the shirt is the `ProductVariant`. The `LineItem` data model is the product variant added to the cart. - -So, you can use the `metadata` attribute of the `LineItem` data model to store the customer’s personalization. - -For example, if you’re asking customers to enter a message to put in a letter they’re purchasing, use the `metadata` attribute of the `LineItem` data model to set the personalized information entered by the customer. - - - -Two line items in the cart having different `metadata` attributes are not considered the same item. So, each line item is managed separately and can have its own quantity. - - - -In more complex cases, you can extend data models, such as the `Product` data model, to add more attributes. You can also create new custom data models to hold your personalized data and logic. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Create a Data Model", - text: "Learn how to create a data model.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Extend a Data Model", - text: "Learn how to extend a data model.", - startIcon: , - showLinkIcon: false - } -]} /> - ---- - -## Build a Custom Storefront - -Due to Medusa's modular architecture, there are no restrictions on what language or framework you use to build the storefront or what design or experience you provide customers. The storefront connects to the Medusa application using the Store API Routes. - -You can build a unique experience around your products that focuses on the customer’s personalization capabilities. - -Medusa provides a Next.js Starter storefront with basic ecommerce functionalities that can be customized. You can also build your own storefront and use Medusa’s client libraries or Store API Routes to communicate with the Medusa application. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Build Your Own Storefront", - text: "Follow this roadmap to learn how to create a storefront.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Pass Personalized Data to the Order - -If you followed the basic approach of using the `metadata` attribute to store the personalized data, pass the personalization data when you add an item to the cart in the storefront using the [Add a line item](https://docs.medusajs.com/api/store#carts_postcartscartlineitems) API Route. This API Route accepts a `metadata` request body parameter that will be stored in the created line item’s `metadata` attribute. - -In the case that you’ve created a custom data model or extended a data model, create a custom API Route that handles saving the personalization data. Then, call that API Route from the storefront before or after adding the item to the cart. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Create API Route", - text: "Learn how to create an API Route.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Access Personalized Data in the Order - -Line items are associated with the placed order. So, if you stored the personalized data in the `metadata` of the line items, expand the `items` relation on the `Order` data model to retrieve the `metadata` attribute of the line items. - -On the other hand, if you’ve created a custom data model or extended a data model, create a custom API route that handles retrieving the personalization data. Alternatively, if the data model you’ve created or customized is associated with the `Order` data model, expand it similarly to the `items` relation. - -To show the personalized data in the Medusa Admin, extend the Medusa Admin to add a widget or a page to show the personalized data. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Expanding Relations in Services", - text: "Learn how to expand relations in services.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Extending Medusa Admin", - text: "Learn how to extend the Medusa Admin with widgets and more.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Fulfill Personalized Products in Orders - -To access the order’s details and fulfill its personalized products, create a subscriber that listens to the `order.placed` event. - -For example, if the customer personalizes a shirt to include an image or message, create a subscriber that listens to the `order.placed` event and sends the personalization to the printing third-party fulfillment service. - -Alternatively, you can create a fulfillment service that integrates a third-party service or mechanism to fulfill the personalized product. This is useful if you want the fulfillment of personalized products to be embedded into Medusa’s existing order fulfillment. - -, - showLinkIcon: false - }, - { - href: "#", - title: "Create a Fulfillment Service", - text: "Learn how to create a fulfillment provider in Medusa.", - startIcon: , - showLinkIcon: false - }, -]} /> - ---- - -## Additional Development - -Refer to other guides in the Medusa Resources or the Medusa Book for additional guidance during your development. diff --git a/www/apps/resources/app/page.mdx b/www/apps/resources/app/page.mdx index e6955f83cd..b4f44b97fb 100644 --- a/www/apps/resources/app/page.mdx +++ b/www/apps/resources/app/page.mdx @@ -18,7 +18,9 @@ import { CreditCardSolid, ClockSolid, DocumentTextSolid, - Stripe + Stripe, + PhotoSolid, + BuildingsSolid } from "@medusajs/icons" # Medusa Learning Resources @@ -43,11 +45,13 @@ Follow the [Medusa Book](https://medusa-docs-v2-git-docs-v2-medusajs.vercel.app) { startIcon: , title: "Order Module", + href: "/commerce-modules/order", showLinkIcon: false }, { startIcon: , title: "Cart Module", + href: "/commerce-modules/product", showLinkIcon: false }, { @@ -103,6 +107,49 @@ Follow the [Medusa Book](https://medusa-docs-v2-git-docs-v2-medusajs.vercel.app) --- +## Recipes + +, + title: "Ecommerce", + href: "/recipes/ecommerce", + showLinkIcon: false + }, + { + startIcon: , + title: "Marketplace", + href: "/recipes/marketplace", + showLinkIcon: false + }, + { + startIcon: , + title: "Digital Products", + href: "/recipes/digital-products", + showLinkIcon: false + }, + { + startIcon: , + title: "Integrate Ecommerce Stack", + href: "/recipes/integrate-ecommerce-stack", + showLinkIcon: false + }, + { + startIcon: , + title: "Commerce Automation", + href: "/recipes/commerce-automation", + showLinkIcon: false + }, + { + startIcon: , + title: "B2B", + href: "/recipes/b2b", + showLinkIcon: false + } +]} /> + +--- + ## SDKs and Tools diff --git a/www/apps/resources/app/recipes/b2b/page.mdx b/www/apps/resources/app/recipes/b2b/page.mdx new file mode 100644 index 0000000000..63aeb42e51 --- /dev/null +++ b/www/apps/resources/app/recipes/b2b/page.mdx @@ -0,0 +1,869 @@ +import { AcademicCapSolid, UsersSolid } from "@medusajs/icons" + +export const metadata = { + title: `B2B Recipe`, +} + +# {metadata.title} + +This recipe provides the general steps to implement a B2B store with Medusa. + +## Overview + +In a B2B store, you provide different types of customers with relevant pricing, products, shopping experience, and more. + +Medusa’s commerce modules, including Sales Channel, Customer, and Pricing modules facilitate implementing this setup. Medusa’s architecture and extendible nature allow you to customize your store based on your use case. + + + +[Visionary: Frictionless B2B ecommerce with Medusa](https://medusajs.com/blog/visionary/) + + + +--- + +## Create B2B Sales Channel + +Use sales channels to set product availability per channel. In this case, create a B2B sales channel that includes only B2B products. + +You can create a sales channel through the Medusa Admin or Admin REST APIs. + +{/* TODO add links */} + +, + showLinkIcon: false, + badge: { + variant: "blue", + children: "Guide Soon" + } + }, + { + href: "!api!/api/admin#sales-channels_postsaleschannels", + title: "Option 2: Using the REST APIs", + text: "Create the sales channel using the REST APIs.", + startIcon: , + showLinkIcon: false + }, +]} /> + +--- + +## Create a Publishable API Key + +Use publishable API keys to specify the context of client requests: + +- You associate the publishable API key with one or more sales channels. +- In a client such as a storefront, you pass the publishable API key in the header of your requests. + +Then, all products retrieved belong to the associated sales channel(s). + +You can create a publishable API key through the Medusa Admin or the Admin REST APIs, then associate it with the B2B sales channel. Later, you'll use this key when developing your B2B storefront. + +{/* TODO add links */} + +### Create Key + +, + showLinkIcon: false, + badge: { + variant: "blue", + children: "Guide Soon" + } + }, + { + href: "!api!/api/admin#api-keys_postapikeys", + title: "Option 2: Using the REST APIs", + text: "Create the publishable API key using the REST APIs.", + startIcon: , + showLinkIcon: false + }, +]} /> + +### Associate Key with Sales Channel + +{/* TODO add links */} + +, + showLinkIcon: false, + badge: { + variant: "blue", + children: "Guide Soon" + } + }, + { + href: "#", + title: "Option 2: Using the REST APIs", + text: "Associate the publishable API key with the B2B sales channel using the REST APIs.", + startIcon: , + showLinkIcon: false + }, +]} /> + +--- + +## Add Products to B2B Sales Channel + +You can create new products or add existing ones to the B2B sales channel using the Medusa Admin or Admin REST APIs. + +### Create Products + +{/* TODO add links */} + +, + showLinkIcon: false, + badge: { + variant: "blue", + children: "Guide Soon" + } + }, + { + href: "!api!/api/admin#products_postproducts", + title: "Using REST APIs", + text: "Create products using the REST APIs.", + startIcon: , + showLinkIcon: false + }, +]} /> + +### Add Products to Sales Channel + +{/* TODO add links */} + +, + showLinkIcon: false, + badge: { + variant: "blue", + children: "Guide Soon" + } + }, + { + href: "!api!/api/admin#sales-channels_postsaleschannelsidproductsbatchadd", + title: "Using REST APIs", + text: "Add the products to the B2B sales channel using the REST APIs.", + startIcon: , + showLinkIcon: false + }, +]} /> + +--- + +## Create B2B Module with Relationship to Customer Groups + +Use customer groups to organize your customers into different groups. Then, you can apply different prices for each group. + +This is useful in B2B sales, as you often negotiate special prices with each customer or company. + +You can create a B2B module that adds necessary data models to represent a B2B company. Then, you link that company to a customer group. Any customer belonging to that group also belongs to the company, meaning they're a B2B customer. + +, + showLinkIcon: false + }, + { + href: "!docs!/basics/data-models", + title: "Create Data Models", + text: "Learn how to create data models.", + startIcon: , + showLinkIcon: false + }, +]} /> + +} + showLinkIcon={false} + className="mt-1" +/> + +
+ In this section, you'll create a B2B module that has a `Company` data model. The `Company` data model has a relationship to the `CustomerGroup` data model of the Customer Module. + + Start by creating the `src/modules/b2b` directory. + + Then, create the file `src/modules/b2b/models/company.ts` with the following content: + + ```ts title="src/modules/b2b/models/company.ts" highlights={[["23", "", "Field will be used to create a relationship to customer groups."]]} collapsibleLines="1-7" expandButtonLabel="Show Imports" + import { BaseEntity } from "@medusajs/utils" + import { + Entity, + PrimaryKey, + Property, + } from "@mikro-orm/core" + + @Entity() + export class Company extends BaseEntity { + @PrimaryKey({ columnType: "text" }) + id!: string + + @Property({ columnType: "text" }) + name: string + + @Property({ columnType: "text" }) + city: string + + @Property({ columnType: "text" }) + country_code: string + + @Property({ columnType: "text" }) + customer_group_id?: string + } + ``` + + This creates a `Company` data model with some relevant fields. Most importantly, it has a `customer_group_id` field. It'll later be used when creating the relationship to the `CustomerGroup` data model in the Customer Module. + + Next, create the migration in the file `src/modules/b2b/migrations/Migration20240516081502.ts` with the following content: + + ```ts title="src/modules/b2b/migrations/Migration20240516081502.ts" + import { Migration } from '@mikro-orm/migrations'; + + export class Migration20240516081502 extends Migration { + + async up(): Promise { + this.addSql('create table if not exists "company" ("id" text not null, "name" text not null, "city" text not null, "country_code" text not null, "customer_group_id" text not null, constraint "company_pkey" primary key ("id"));'); + } + + async down(): Promise { + this.addSql('drop table if exists "company" cascade;'); + } + } + ``` + + You'll run the migration to reflect the data model in the database after finishing the module definition. + + Before creating the module's main service, create the file `src/types/b2b/index.ts` with some helper types: + + ```ts title="src/types/b2b/index.ts" + import { CustomerGroupDTO } from "@medusajs/types" + + export type CompanyDTO = { + id: string + name: string + city: string + country_code: string + customer_group_id?: string + customer_group?: CustomerGroupDTO + } + + export type CreateCompanyDTO = { + name: string + city: string + country_code: string + customer_group_id?: string + } + + ``` + + You can now create the module's main service at `src/modules/b2b/service.ts` with the following content: + +export const mainServiceHighlights = [ + ["42", "relationships", "Implement the relationship to the `CustomerGroup` data model in the Customer Module."], + ["56", "create", "Implement a create method to create a company."] +] + + ```ts title="src/modules/b2b/service.ts" highlights={mainServiceHighlights} collapsibleLines="1-6" expandButtonLabel="Show Imports" + import { ModulesSdkUtils } from "@medusajs/utils" + import { ModuleJoinerConfig, ModulesSdkTypes } from "@medusajs/types" + import { Modules } from "@medusajs/modules-sdk" + import { Company } from "./models/company"; + import { CompanyDTO, CreateCompanyDTO } from "../../types/b2b"; + + type InjectedDependencies = { + companyService: ModulesSdkTypes.InternalModuleService + } + + type AllModelsDTO = { + Company: { + dto: CompanyDTO + } + } + + class B2bModuleService extends ModulesSdkUtils + .abstractModuleServiceFactory< + InjectedDependencies, + CompanyDTO, + AllModelsDTO + >(Company, []) { + companyService_: ModulesSdkTypes.InternalModuleService + + constructor({ companyService }: InjectedDependencies) { + // @ts-ignore + super(...arguments) + this.companyService_ = companyService + } + + __joinerConfig(): ModuleJoinerConfig { + return { + serviceName: "b2bModuleService", + alias: [ + { + name: ["company"], + args: { + entity: Company.name + } + } + ], + relationships: [ + { + serviceName: Modules.CUSTOMER, + alias: "customer_group", + primaryKey: "id", + foreignKey: "customer_group_id", + args: { + methodSuffix: "CustomerGroups" + } + } + ] + } + } + + async create (data: CreateCompanyDTO): Promise { + const company = this.companyService_.create(data) + + return company + } + } + + export default B2bModuleService + ``` + + This creates a `B2bModuleService` that extends the service factory and implements: + + - The module's relationship to the `CustomerGroup` data model in the Customer Module within the `__joinerConfig` method. + - A `create` method to create a company. + + Next, create the module definition at `src/modules/b2b/index.ts` with the following content: + + ```ts title="src/modules/b2b/index.ts" + import B2bModuleService from "./service"; + + export default { + service: B2bModuleService, + } + ``` + + Finally, add the module to the `modules` object in `medusa-config.js`: + + ```js title="medusa-config.js" + module.exports = defineConfig({ + // ... + modules: { + b2bModuleService: { + resolve: "./modules/b2b", + definition: { + isQueryable: true + } + }, + } + }) + ``` + + You can now run migrations with the following commands: + + ```bash npm2yarn + npx medusa migrations run + ``` + + ### Add Create Company API Route + + To test out using the B2B Module, you'll add an API route to create a company. + + Start by creating the file `src/workflows/create-company.ts` with the following content: + +export const workflowHighlights = [ + ["23", "tryToCreateCustomerGroupStep", "This step creates the customer group if its data is passed in the `customer_group` property."], + ["36", "createCustomerGroupsWorkflow", "Use the `createCustomerGroupsWorkflow` defined by Medusa to create the customer group."], + ["44", "", "Set the ID of the new customer group in the `customer_group_id` property so that it's added to the created company."], + ["54", "createCompanyStep", "This step creates the company."], +] + + ```ts title="src/workflows/create-company.ts" highlights={workflowHighlights} collapsibleLines="1-12" expandButtonLabel="Show Imports" + import { + StepResponse, + createStep, + createWorkflow + } from "@medusajs/workflows-sdk"; + import { + createCustomerGroupsWorkflow + } from "@medusajs/core-flows" + import { CreateCustomerGroupDTO } from "@medusajs/types"; + import { CompanyDTO, CreateCompanyDTO } from "../types/b2b"; + import B2bModuleService from "../modules/b2b/service"; + + export type CreateCompanyWorkflowInput = CreateCompanyDTO & { + customer_group?: CreateCustomerGroupDTO + } + + type CreateCompanyWorkflowOutput = { + company: CompanyDTO + } + + type CreateCustomerGroupStepInput = CreateCompanyWorkflowInput + + const tryToCreateCustomerGroupStep = createStep( + "try-to-create-customer-group-step", + async ( + { + customer_group, + ...company + }: CreateCustomerGroupStepInput, + { container }) => { + if (!customer_group) { + return new StepResponse({ company }) + } + + // create customer group + const { result } = await createCustomerGroupsWorkflow( + container + ).run({ + input: { + customersData: [customer_group] + } + }) + + company.customer_group_id = result[0].id + + return new StepResponse({ company }) + } + ) + + export type CreateCompanyStep = { + companyData: CreateCompanyDTO + } + + const createCompanyStep = createStep( + "create-company-step", + async ( + { companyData }: CreateCompanyStep, + { container }) => { + const b2bModuleService: B2bModuleService = container + .resolve( + "b2bModuleService" + ) + + const company = await b2bModuleService.create( + companyData + ) + + return new StepResponse({ company }) + } + ) + + export const createCompanyWorkflow = createWorkflow< + CreateCompanyWorkflowInput, + CreateCompanyWorkflowOutput + >( + "create-company", + function (input) { + const { + company: companyData + } = tryToCreateCustomerGroupStep(input) + + const company = createCompanyStep({ companyData }) + + return company + } + ) + ``` + + You create a workflow with two steps: + + 1. The first one tries to create a customer group if its data is provided in the `customer_group` property and sets its value in the `customer_group_id` field. + 2. The second one creates the company. + + Then, create the file `src/api/admin/b2b/company/route.ts` with the following content: + + ```ts title="src/api/admin/b2b/company/route.ts" collapsibleLines="1-9" expandButtonLabel="Show Imports" + import type { + MedusaRequest, + MedusaResponse + } from "@medusajs/medusa"; + import { + CreateCompanyWorkflowInput, + createCompanyWorkflow + } from "../../../../workflows/create-company" + + type CreateCompanyReq = CreateCompanyWorkflowInput + + export async function POST( + req: MedusaRequest, + res: MedusaResponse + ) { + const { result } = await createCompanyWorkflow(req.scope) + .run({ + input: req.body + }) + + res.json({ + company: result.company + }) + } + ``` + + The API route uses the workflow to create the company. It passes the request body as the workflow's input. + + ### Test API Route + + To test the API route, start the Medusa application: + + ```bash npm2yarn + npm run dev + ``` + + Next, make sure you authenticate as an admin user as explained in [this Authentication guide](!api!/api/admin#authentication). + + Then, send a `POST` request to the `/admin/b2b/company` API route: + + ```bash + curl -X POST 'localhost:9000/admin/b2b/company' \ + --header 'Content-Type: application/json' \ + --header 'Authorization: Bearer {jwt_token}' \ + --data '{ + "name": "Acme", + "city": "London", + "country_code": "gb", + "customer_group": { + "name": "B2B" + } + }' + ``` + + This creates a company and its associated customer group. + + + + You can alternatively pass a `customer_group_id` to use an existing customer group. + + + +
+ +## Add B2B Customers + +After adding your B2B customer group, add B2B customers and assign them to the B2B customer group. + +You can do that through the Medusa Admin or Admin REST APIs. + +### Create Customers + +{/* TODO add links */} + +, + showLinkIcon: false, + badge: { + variant: "blue", + children: "Guide Soon" + } + }, + { + href: "!api!/api/admin#customers_postcustomers", + title: "Option 2: Using the REST APIs", + text: "Create the customers using the REST APIs.", + startIcon: , + showLinkIcon: false + }, +]} /> + +### Assign Customers to Groups + +{/* TODO add links */} + +, + showLinkIcon: false, + badge: { + variant: "blue", + children: "Guide Soon" + } + }, + { + href: "!api!/api/admin#customer-groups_postcustomergroupsidcustomersbatch", + title: "Option 2: Using the REST APIs", + text: "Assign the customers to the B2B customer group using the REST APIs.", + startIcon: , + showLinkIcon: false + }, +]} /> + +--- + +## Create B2B Price List + +Use price lists to set different prices for each B2B customer group, among other conditions. + +You can create a price list using the Medusa Admin or the Admin REST APIs. Make sure to set the B2B customer group as a condition. + +{/* TODO add links */} + +, + showLinkIcon: false, + badge: { + variant: "blue", + children: "Guide Soon" + } + }, + { + href: "!api!/api/admin#price-lists_postpricelists", + title: "Using REST APIs", + text: "Create the price list using the REST APIs.", + startIcon: , + showLinkIcon: false + }, +]} /> + +--- + +## Create Custom Data Model + +To implement a more advanced B2B sales flow, add custom data models such as `Company`, `Employee`, `Admin`, and `Buyer` to your B2B module. + +This provides more granular control of your B2B sales and allows you to build features like privileges, limits, and more. + +} + showLinkIcon={false} +/> + +--- + +## Create an API Route to Check Customers + +On the frontend clients communicating with your store, such as the storefront, you need to check whether the currently logged-in customer is a B2B customer. + +The API route can check if the customer has any group with an associated company. + +} + showLinkIcon={false} +/> + +
+ + For example, create the API route `src/api/store/b2b/check-customer/route.ts` with the following content: + +export const checkCustomerHighlights = [ + ["16", "retrieve", "Retrieve the customer along with its groups."], + ["20", "list", "List the companies that have a customer group ID matching any of the customer's group IDs."], + ["25", "", "Return whether there are any companies associated with the customer's groups."] +] + + ```ts title="src/api/store/b2b/check-customer/route.ts" highlights={checkCustomerHighlights} collapsibleLines="1-5" expandButtonLabel="Show Imports" + import type { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/medusa"; + import { ModuleRegistrationName } from "@medusajs/modules-sdk"; + import { ICustomerModuleService } from "@medusajs/types"; + import B2bModuleService from "../../../../modules/b2b/service"; + + export async function GET( + req: AuthenticatedMedusaRequest, + res: MedusaResponse + ) { + const customerModuleService: ICustomerModuleService = req + .scope.resolve(ModuleRegistrationName.CUSTOMER) + const b2bModuleService: B2bModuleService = req.scope.resolve( + "b2bModuleService" + ) + + const customer = await customerModuleService.retrieve(req.auth.actor_id, { + relations: ["groups"] + }) + + const companies = await b2bModuleService.list({ + customer_group_id: customer.groups.map((group) => group.id) + }) + + res.json({ + is_b2b: companies.length > 0 + }) + } + ``` + + This creates a `GET` API Route at `/store/b2b/check-customer` that: + + 1. Retrieves the customer along with its groups using the Customer Module's main service. + 2. Lists the companies that have a customer group ID matching any of the customer's group IDs. + 3. Return an `is_b2b` field whose value is `true` if there are any companies associated with the customer's groups. + + Before using the API route, create the file `src/api/middlewares.ts` with the following content: + + ```ts title="src/api/middlewares.ts" + import { MiddlewaresConfig, authenticate } from "@medusajs/medusa" + + export const config: MiddlewaresConfig = { + routes: [ + { + matcher: "/store/b2b*", + middlewares: [ + authenticate("store", ["bearer", "session"]) + ], + }, + ], + } + ``` + + This ensures that only logged-in customers can use the API route. + + ### Test API Route + + To test out the API route: + + 1. Start the Medusa application. + 2. Obtain an authentication JWT token for a new customer. Do that by sending a `POST` request to the `/auth/store/emailpass` API Route: + + ```bash + curl -X POST 'http://localhost:9000/auth/store/emailpass' \ + -H 'Content-Type: application/json' \ + --data-raw '{ + "email": "test@medusajs.com", + "password": "supersecret" + }' + ``` + + 3. Send a `POST` request to the `/store/customers` API route that registers the customer. Make sure to pass the authentication JWT token from the previous token in the header: + + ```bash + curl -X POST 'http://localhost:9000/store/customers' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer {jwt_token}' \ + --data-raw '{ + "email": "test@medusajs.com", + "password": "supersecret" + }' + ``` + + 4. Add the customer to the B2B group as explained in a [previous section](#add-b2b-customers). + 5. Send a `GET` request to the `/store/b2b/check-customer` API route you created in this section: + + ```bash + curl 'http://localhost:9000/store/b2b/check-customer' \ + --header 'Authorization: Bearer {jwt_token}' + ``` + + You'll receive a JSON response as the following: + + ```json + { + "is_b2b": true + } + ``` + +
+ +## Customize Admin + + + +Admin customizations are coming soon! + + + +Based on your use case, you may need to customize the Medusa Admin to add new widgets or pages. + +The Medusa Admin plugin can be extended to add widgets, new pages, and setting pages. + +, + showLinkIcon: false + }, + { + href: "!docs!/advanced-development/admin/ui-routes", + title: "Create Admin UI Routes", + text: "Learn how to add new pages to your Medusa Admin.", + startIcon: , + showLinkIcon: false + }, + { + href: "!docs!/advanced-development/admin/setting-pages", + title: "Create Admin Setting Page", + text: "Learn how to add new page to the Medusa Admin settings.", + startIcon: , + showLinkIcon: false + }, +]} /> + +--- + +## Customize Storefront + +Medusa provides a Next.js storefront to use with your application. You can either customize it or build your own to represent your B2B features. + +Use the publishable API key you associated with your B2B sales channel in the storefront to ensure only B2B products are retrieved. + +, + showLinkIcon: false + }, + { + href: "!docs!/storefront-development/tips", + title: "Storefront Tips", + text: "Find tips on developing a custom storefront.", + startIcon: , + showLinkIcon: false + }, +]} /> + +} + showLinkIcon={false} + className="mt-1" +/> diff --git a/www/apps/resources/app/recipes/commerce-automation/page.mdx b/www/apps/resources/app/recipes/commerce-automation/page.mdx new file mode 100644 index 0000000000..d670d91465 --- /dev/null +++ b/www/apps/resources/app/recipes/commerce-automation/page.mdx @@ -0,0 +1,1064 @@ +import { AcademicCapSolid, BoltSolid } from "@medusajs/icons" +import { LearningPath } from "docs-ui" + +export const metadata = { + title: `Commerce Automation Recipe`, +} + +# {metadata.title} + +This recipe provides the general steps to implement a B2B store with Medusa. + +## Overview + +Commerce automation is essential for businesses to save costs, provide a better user experience, and avoid manual, repetitive tasks that lead to human errors. Businesses utilize automation in different domains, including marketing, customer support, and order management. + +Medusa provides the necessary architecture and tools to implement commerce automation for order management, customer service, and more. You can perform an asynchronous action when an event is triggered, schedule a job that runs at a specified interval, and more. + +--- + +## Re-Stock Notifications + +Customers may be interested in a product that is currently out of stock. + +To implement sending restock notifications, you can: + +- Create a module that manages the customers subscribed to a variant's restock notification. +- Create relationships to the Product and Sales Channel modules. A variant's inventory is managed by the sales channel's associated stock locations. +- Create an API route that allows customers to subscribe to a variant's restock notification. +- Create a subscriber that listens to the `inventory-item.updated` event and sends a notification to the subscribed customers if the variant's quantity is more than `0`. + + + +The `inventory-item.updated` event is currently not emitted. + + + +, + showLinkIcon: false + }, + { + href: "!docs!/basics/data-models", + title: "Create a Data Model", + text: "Learn how to create a data model.", + startIcon: , + showLinkIcon: false + }, + { + href: "!docs!/advanced-development/modules/module-relationships", + title: "Module Relationships", + text: "Learn how to create module relationships.", + startIcon: , + showLinkIcon: false + }, +]} /> + +, + showLinkIcon: false + }, + { + href: "!docs!/basics/events-and-subscribers", + title: "Create a Subscriber", + text: "Learn how to create a subscriber in Medusa.", + startIcon: , + showLinkIcon: false + }, +]} className="mt-1" /> + +
+ + In this example, you'll create a Restock Notification Module with the features explained above. + + ### Create Restock Notification Module + + Start by creating the `src/modules/restock-notification` directory. + + Then, create the file `src/modules/restock-notification/models/restock-notification.ts` with the following content: + +export const restockModelHighlights = [ + ["14", "email", "The email of the customer to send the notification to when the item is restocked."], + ["17", "variant_id", "The ID of the variant the customer is subscribed to."], + ["20", "sales_channel_id", "The ID of the sales channel the customer is viewing the product variant from."] +] + + ```ts title="src/modules/restock-notification/models/restock-notification.ts" highlights={restockModelHighlights} collapsibleLines="1-7" expandButtonLabel="Show Imports" + import { BaseEntity } from "@medusajs/utils" + import { + Entity, + PrimaryKey, + Property + } from "@mikro-orm/core"; + + @Entity() + export class RestockNotification extends BaseEntity { + @PrimaryKey({ columnType: "text" }) + id!: string + + @Property({ columnType: "text" }) + email: string + + @Property({ columnType: "text" }) + variant_id: string + + @Property({ columnType: "text" }) + sales_channel_id: string + } + ``` + + This creates a `RestockNotification` data model with the following fields: + + - `email`: The email of the customer to send the notification to when the item is restocked. + - `variant_id`: The ID of the variant the customer is subscribed to. This will later be used to form a relationship with the `ProductVariant` data model of the Product Module. + - `sales_channel_id`: The ID of the sales channel the customer is viewing the product variant from. This will later be used to form a relationship with the `SalesChannel` data model of the Sales Channel Module. + + Since a variant's inventory is managed based on the locations of each sales channel, you have to specify which sales channel to check stock quantity in. + + Next, create the file `src/modules/restock-notification/migrations/Migration20240516140616.ts` with the following content: + + ```ts title="src/modules/restock-notification/migrations/Migration20240516140616.ts" + import { Migration } from '@mikro-orm/migrations'; + + export class Migration20240516140616 extends Migration { + + async up(): Promise { + this.addSql('create table if not exists "restock_notification" ("id" text not null, "email" text not null, "variant_id" text not null, "sales_channel_id" text not null, constraint "restock_notification_pkey" primary key ("id"));'); + } + + async down(): Promise { + this.addSql('drop table if exists "restock_notification" cascade;'); + } + + } + ``` + + You'll run the migration to reflect the changes on the database after finishing the module's definition. + + Now, create the file `src/types/restock-notification/index.ts` that holds common types to be used in the module's main service: + + ```ts title="src/types/restock-notification/index.ts" + import { ProductVariantDTO, SalesChannelDTO } from "@medusajs/types" + + export type RestockNotificationDTO = { + id: string + email: string + variant_id: string + sales_channel_id: string + variant?: ProductVariantDTO + sales_channel?: SalesChannelDTO + } + + export type CreateRestockNotificationDTO = { + email: string + variant_id: string + sales_channel_id: string + } + + ``` + + Then, create the module's main service at `src/modules/restock-notification/service.ts` with the following content: + +export const restockModuleService = [ + ["17", "abstractModuleServiceFactory", "Extend the service factory to have basic data management features."], + ["43", "", "Define a relationship to the `ProductVariant` data model of the Product Module."], + ["52", "", "Define a relationship to the `SalesChannel` data model of the Sales Channel Module."], + ["61", "create", "Implement the method to create a restock notification."] +] + + ```ts title="src/modules/restock-notification/service.ts" highlights={restockModuleService} collapsibleLines="1-5" expandButtonLabel="Show Imports" + import { Modules, ModulesSdkUtils } from "@medusajs/utils" + import { RestockNotification } from "./models/restock-notification" + import { ModuleJoinerConfig, ModulesSdkTypes } from "@medusajs/types" + import { CreateRestockNotificationDTO, RestockNotificationDTO } from "../../types/restock-notification" + + type InjectedDependencies = { + restockNotificationService: ModulesSdkTypes.InternalModuleService + } + + type AllModelDTOs = { + RestockNotification: { + dto: RestockNotificationDTO + } + } + + class RestockNotificationModuleService extends ModulesSdkUtils + .abstractModuleServiceFactory< + InjectedDependencies, + RestockNotificationDTO, + AllModelDTOs + >(RestockNotification, []) { + restockNotificationService_: ModulesSdkTypes.InternalModuleService + + constructor({ restockNotificationService }: InjectedDependencies) { + // @ts-ignore + super(...arguments) + this.restockNotificationService_ = restockNotificationService + } + + __joinerConfig(): ModuleJoinerConfig { + return { + serviceName: "restockNotificationModuleService", + alias: [ + { + name: "restock_notification", + args: { + entity: RestockNotification.name + } + } + ], + relationships: [ + { + serviceName: Modules.PRODUCT, + alias: "variant", + primaryKey: "id", + foreignKey: "variant_id", + args: { + methodSuffix: "Variants" + } + }, + { + serviceName: Modules.SALES_CHANNEL, + alias: "sales_channel", + primaryKey: "id", + foreignKey: "sales_channel_id", + } + ] + } + } + + async create (data: CreateRestockNotificationDTO): Promise { + const restockNotification = await this.restockNotificationService_.create( + data + ) + + return restockNotification + } + } + + export default RestockNotificationModuleService + ``` + + In the module's main service, you: + + - Extend the service factory to have basic data management features. + - Define a relationship to the `ProductVariant` data model of the Product Module. + - Define a relationship to the `SalesChannel` data model of the Sales Channel Module. + - Implement the `create` method to create a restock notification. + + Next, create the module's definition file `src/modules/restock-notification/index.ts` with the following content: + + ```ts title="src/modules/restock-notification/index.ts" + import RestockNotificationModuleService from "./service"; + + export default { + service: RestockNotificationModuleService + } + ``` + + Finally, add the module to the `modules` object in `medusa-config.js`: + + ```js title="medusa-config.js" + module.exports = defineConfig({ + // ... + modules: { + "restockNotificationModuleService": { + resolve: "./modules/restock-notification", + definition: { + isQueryable: true + } + }, + } + }) + ``` + + You can now run the module's migrations with the following command: + + ```bash npm2yarn + npx medusa migrations run + ``` + + ### Create Restock Notification API Route + + Create the file `src/api/store/restock-notification/route.ts` with the following content: + + ```ts title="src/api/store/restock-notification/route.ts" collapsibleLines="1-10" expandButtonLabel="Show Imports" + import type { + MedusaRequest, + MedusaResponse + } from "@medusajs/medusa"; + import RestockNotificationModuleService + from "../../../modules/restock-notification/service"; + import { + CreateRestockNotificationDTO + } from "../../../types/restock-notification"; + + type RestockNotificationReq = CreateRestockNotificationDTO + + export async function POST( + req: MedusaRequest, + res: MedusaResponse + ) { + const restockNotificationModuleService: + RestockNotificationModuleService = req.scope.resolve( + "restockNotificationModuleService" + ) + + await restockNotificationModuleService.create( + req.body + ) + + res.json({ + success: true + }) + } + ``` + + This creates a `POST` API route at `/store/restock-notification`. It accepts the `email`, `variant_id`, and `sales_channel_id` request body parameters and creates a restock notification. + + ### Create Inventory Item Updated Subscriber + + + + The `inventory-item.updated` event is currently not emitted. The subscriber will only work once the event is emitted. + + + + To handle the sending of the restock notifications, create the file `src/subscribers/inventory-item-update.ts` with the following content: + +export const subscriberHighlights = [ + ["48", "inventoryVariantLinkService", "Retrieve an instance of the link service for the product-variant-inventory-item link module."], + ["55", "inventoryVariantItems", "Retrieve the variants linked to the updated inventory item."], + ["68", "restockQuery", "Assemble the query to retrieve the restock notifications with their associated variants."], + ["81", "restockNotifications", "Retrieve the restock notifications using the query."], + ["84", "salesChannelLocationService", "Retrieve an instance of the link service for the sales-channel-stock-location link module."], + ["93", "salesChannelLocations", "Retrieve the stock locations linked to the restock notification's sales channel."], + ["107", "availableQuantity", "Retrieve the available quantity of the variant in the retrieved stock locations."], + ["116", "continue", "Only send the notification if the available quantity is greater than `0`"], + ["119", "create", "Send the notification to the customer using the Notification Module."], + ["122", '"test_template"', "Replace with the actual template used for sending the email."], + ["123", "data", "The data to send along to the third-party service sending the notification."], + ["131", "delete", "Delete the restock notification to not send the notification again."] +] + + ```ts title="src/subscribers/inventory-item-update.ts" highlights={subscriberHighlights} collapsibleLines="1-23" expandButtonLabel="Show Imports" + import type { + SubscriberArgs, + SubscriberConfig, + } from "@medusajs/medusa" + import { + IInventoryServiceNext, + INotificationModuleService, + RemoteQueryFunction + } from "@medusajs/types" + import { + ContainerRegistrationKeys, + Modules, + remoteQueryObjectFromString + } from "@medusajs/utils" + import { + RestockNotificationDTO + } from "../types/restock-notification" + import { + RemoteLink + } from "@medusajs/modules-sdk" + import RestockNotificationModuleService + from "../modules/restock-notification/service" + + // subscriber function + export default async function inventoryItemUpdateHandler({ + data, + container + }: SubscriberArgs<{ id: string }>) { + const remoteQuery: RemoteQueryFunction = container.resolve( + ContainerRegistrationKeys.REMOTE_QUERY + ) + const remoteLink: RemoteLink = container.resolve( + ContainerRegistrationKeys.REMOTE_LINK + ) + const restockNotificationModuleService: + RestockNotificationModuleService = container.resolve( + "restockNotificationModuleService" + ) + const inventoryModuleService: IInventoryServiceNext = + container.resolve(Modules.INVENTORY) + const notificationModuleService: INotificationModuleService = + container.resolve( + Modules.NOTIFICATION + ) + + const inventoryItemId = "data" in data ? data.data.id : data.id + + const inventoryVariantLinkService = remoteLink.getLinkModule( + Modules.PRODUCT, + "variant_id", + Modules.INVENTORY, + "inventory_item_id" + ) + + const inventoryVariantItems = + await inventoryVariantLinkService.list({ + inventory_item_id: [inventoryItemId] + }) as { + variant_id: string, + inventory_item_id: string + }[] + + if (!inventoryVariantItems.length) { + console.log("no inventory variant items") + return + } + + const restockQuery = remoteQueryObjectFromString({ + entryPoint: "restock_notification", + fields: [ + "email", + "variant.name" + ], + variables: { + filters: { + variant_id: inventoryVariantItems[0].variant_id + } + } + }) + + const restockNotifications: RestockNotificationDTO[] = + await remoteQuery(restockQuery) + + const salesChannelLocationService = remoteLink.getLinkModule( + Modules.SALES_CHANNEL, + "sales_channel_id", + Modules.STOCK_LOCATION, + "stock_location_id" + ) + + for (const restockNotification of restockNotifications) { + const salesChannelLocations = + await salesChannelLocationService.list({ + sales_channel_id: [ + restockNotification.sales_channel_id + ] + }) as { + stock_location_id: string + sales_channel_id: string + }[] + + if (!salesChannelLocations.length) { + continue + } + + const availableQuantity = await inventoryModuleService + .retrieveAvailableQuantity( + inventoryItemId, + salesChannelLocations.map( + (salesChannelLocation) => + salesChannelLocation.stock_location_id + ) + ) + + if (availableQuantity === 0) { + continue + } + + notificationModuleService.create({ + to: restockNotification.email, + channel: "email", + template: "test_template", + data: { + variant_id: restockNotification.variant_id, + variant_name: restockNotification.variant.title + // other data... + } + }) + + // delete the restock notification + await restockNotificationModuleService.delete(restockNotification.id) + } + } + + // subscriber config + export const config: SubscriberConfig = { + event: "inventory-item.updated", + } + ``` + + This adds a subscriber to the `inventory-item.updated` event. In the subscriber handler function, you: + + - Retrieve an instance of the link service for the product-variant-inventory-item link module. + - Retrieve the variants linked to the updated inventory item. + - Retrieve the restock notifications of those variants. + - For each restock notification, you: + - Retrieve its quantity based on the stock location associated with the restock notification's sales channel. + - If the quantity is greater than `0`, you send a notification using the Notification Module and delete the restock notification. + +
+ +--- + +## Automated Customer Support + +Customer support is essential to build a store's brand and customer loyalty. This can include integrating with third-party services or automating notifications sent to customers when changes happen related to their orders, returns, exchanges, and more. + +You can use the Notification Module to send notifications when an action is triggered, such as when a customer or their order is updated. + + + +The [Events reference](../../events-reference/page.mdx) shows an extensive list of events triggered by the each commerce module. + + + +Medusa also provides Notification Provider Modules that integrate with third-party services, such as SendGrid. + +, + showLinkIcon: false + }, + { + href: "!docs!/basics/events-and-subscribers", + title: "Create Subscriber", + text: "Learn how to create a subscriber to handle events.", + startIcon: , + showLinkIcon: false + }, +]} /> + +--- + +## Automatic Data Synchronization + +As your commerce store grows, you'll likely need to synchronize data across different systems. For example, you need to synchronize data with an ERP system or a data warehouse. + +You can do that by: + +- Creating a workflow that implements the synchronization steps, along with retry and rollback logic. +- Creating a scheduled job that executes the workflow automatically at the specified time pattern. + + + +Scheduled jobs are coming soon. + + + +} + showLinkIcon={false} +/> + +
+ + For example, create the file `src/workflows/sync-products.ts` with the following content: + +export const syncProductsWorkflowHighlight = [ + ["9", "retrieveStoreStep", "A step that retrieves the store by its ID."], + ["25", "retrieveProductsToUpdateStep", "A step that retrieves the products to update based on a last update date."], + ["45", "syncProductsStep", "A step to sync the product with a third-party service."], + ["48", "productSyncModuleService", "Assuming this is a custom module's main service that provides connection to the third-party service."], + ["52", "productsBeforeSync", "Retrieve old product data from third-party service for compensation function."], + ["57", "sync", "Sync the product data in the third-party service."], + ["61", "", "Pass products data before sync to compensation function."], + ["64", "", "A compensation function to revert the sync when an error occurs."], + ["70", "sync", "Revert the product's data in the third-party service to its old data before the synchronization."], + ["79", "updateStoreLastSyncStep", "A step to update the `last_sync_data` of the store."], + ["85", "prevLastSyncDate", "Retrieve the previous value of `last_sync_date` to pass it to compensation function."], + ["87", "update", "Update the `last_sync_date` of the store."], + ["95", "", "Pass previous last sync date to compensation function."], + ["98", "", "A compensation function to revert the update of `last_sync_data` if an error occurs."], + ["114", "syncProductsWorkflow", "Define the workflow that uses the above steps."] +] + + ```ts title="src/workflows/sync-products.ts" highlights={syncProductsWorkflowHighlight} + import { ModuleRegistrationName } from "@medusajs/modules-sdk"; + import { IProductModuleService, IStoreModuleService, ProductDTO, StoreDTO } from "@medusajs/types"; + import { StepResponse, createStep, createWorkflow } from "@medusajs/workflows-sdk"; + + type RetrieveStoreStepInput = { + id: string + } + + const retrieveStoreStep = createStep( + "retrieve-store-step", + async ({ id }: RetrieveStoreStepInput, {container}) => { + const storeModuleService: IStoreModuleService = + container.resolve(ModuleRegistrationName.STORE) + + const store = await storeModuleService.retrieve(id) + + return new StepResponse({ store }) + } + ) + + type RetrieveProductsToUpdateStepInput = { + last_sync_date?: string + } + + const retrieveProductsToUpdateStep = createStep( + "retrieve-products-to-update-step", + async ({ last_sync_date }: RetrieveProductsToUpdateStepInput, { container }) => { + const productModuleService: IProductModuleService = + container.resolve(ModuleRegistrationName.PRODUCT) + + const products = await productModuleService.list({ + updated_at: { + $gt: last_sync_date + } + }) + + return new StepResponse({ products }) + } + ) + + type SyncProductsStepInput = { + products: ProductDTO[] + } + + const syncProductsStep = createStep( + "sync-products-step", + async ({ products }: SyncProductsStepInput, { container }) => { + const productSyncModuleService = container.resolve( + "productSyncModuleService" + ) + + const productsBeforeSync = await productSyncModuleService.list({ + id: products.map((product) => product.id) + }) + + for (const product of products) { + await productSyncModuleService.sync(product) + } + + return new StepResponse({}, { + products: productsBeforeSync + }) + }, + async ({ products }, { container }) => { + const productSyncModuleService = container.resolve( + "productSyncModuleService" + ) + + for (const product of products) { + await productSyncModuleService.sync(product) + } + } + ) + + type UpdateStoreLastSyncStepInput = { + store: StoreDTO + } + + const updateStoreLastSyncStep = createStep( + "update-store-last-sync-step", + async ({ store }: UpdateStoreLastSyncStepInput, { container }) => { + const storeModuleService: IStoreModuleService = + container.resolve(ModuleRegistrationName.STORE) + + const prevLastSyncDate = store.metadata.last_sync_date + + await storeModuleService.update(store.id, { + metadata: { + last_sync_date: (new Date()).toString() + } + }) + + return new StepResponse({}, { + id: store.id, + last_sync_date: prevLastSyncDate + }) + }, + async ({ id, last_sync_date }, { container }) => { + const storeModuleService: IStoreModuleService = + container.resolve(ModuleRegistrationName.STORE) + + await storeModuleService.update(id, { + metadata: { + last_sync_date + } + }) + } + ) + + type SyncProductsWorkflowInput = { + store_id: string + } + + export const syncProductsWorkflow = createWorkflow< + SyncProductsWorkflowInput, {} + >( + "sync-products-workflow", + function ({ store_id }: SyncProductsWorkflowInput) { + const { store } = retrieveStoreStep({ + id: store_id + }) + + const { products } = retrieveProductsToUpdateStep({ + last_sync_date: store.metadata.last_sync_date + }) + + syncProductsStep({ + products + }) + + updateStoreLastSyncStep({ + store + }) + } + ) + ``` + + This creates a workflow with the following steps: + + 1. Retrieve the store by its ID. + 2. Retrieve products to update based on the last date and time the products were synced. The last sync date is retrieved from the store's `metadata` field. + 3. Sync the retrieved products with a third-party service. It's assumed that the connection to the third-party service is implemented within a custom module's main service. + 4. Update the last sync date of the store to the current date. + +
+ +--- + +## Order Management Automation + +Using Medusa's architecture and commerce features, you can automate a large amount of order management functionalities. + +To handle events within an order flow and automate actions, create a subscriber. For example, create a subscriber that listens to the `order.placed` event and automatically creates a fulfillment if predefined conditions are met. + + + +The `order.placed` event is currently not emitted. + + + +, + showLinkIcon: false + }, + { + href: "/events-reference", + title: "Events Reference", + text: "Check out triggered events by each commerce module.", + startIcon: , + showLinkIcon: false + }, +]} /> + +--- + +## Automated RMA Flow + +Businesses must optimize their Return Merchandise Authorization (RMA) flow to ensure a good customer experience and service. By automating the flow, customers request to return their received items, and businesses quickly support them. + +Medusa's commerce features are geared towards automating RMA flows and ensuring a good customer experience: + +- Customers can create order returns from the storefront. Merchants then receive a notification and handle the return from the Medusa Admin. +- Merchants can make order changes and request the customer's approval for them. The customer can also send any additional payment if necessary. +- Every order-related action triggers an event, which you can listen to with a subscriber. This allows you to handle order events to automate actions. + +, + showLinkIcon: false + }, + { + href: "!docs!/basics/events-and-subscribers", + title: "Create a Subscriber", + text: "Learn how to create a subscriber in Medusa.", + startIcon: , + showLinkIcon: false + }, + { + href: "/events-reference", + title: "Events Reference", + text: "Check out triggered events by each commerce module.", + startIcon: , + showLinkIcon: false + }, +]} /> + +--- + +## Customer Segmentation + +Businesses use customer segmentation to organize customers into different groups and then apply different price rules to these groups. + +Medusa's commerce modules provide the necessary features to implement this use case: + +- The Customer Module provides a customer groups feature to organize customers into customer groups. +- The Pricing Module provides the features to specify prices based on a condition, such as the group of the customer. + +For example, to group customers with over twenty orders: + +1. Create a subscriber that listens to the `order.placed` event. +2. If the customer has more than 20 orders, add them to the VIP customer group. + + + +The `order.placed` event is currently not emitted. + + + +, + showLinkIcon: false + }, + { + href: "/commerce-modules/pricing", + title: "Pricing Module", + text: "Learn about the Pricing Module and its features.", + startIcon: , + showLinkIcon: false + }, + { + href: "!docs!/basics/events-and-subscribers", + title: "Create a Subscriber", + text: "Learn how to create a subscriber in Medusa.", + startIcon: , + showLinkIcon: false + }, +]} /> + +
+ + Here’s an example of a subscriber that listens to the `order.placed` event and checks whether the customer should be added to the VIP customer group based on their number of orders: + + + + The `order.placed` event is currently not emitted. + + + + ```ts title="src/subscribers/add-custom-to-vip.ts" collapsibleLines="1-12" expandButtonLabel="Show Imports" + import type { + SubscriberArgs, + SubscriberConfig, + } from "@medusajs/medusa" + import { + ModuleRegistrationName + } from "@medusajs/modules-sdk" + import { + ICustomerModuleService, + IOrderModuleService + } from "@medusajs/types" + + export default async function orderCreatedHandler({ + data, + container + }: SubscriberArgs<{ id: string }>) { + const orderId = "data" in data ? data.data.id : data.id + + const orderModuleService: IOrderModuleService = container + .resolve(ModuleRegistrationName.ORDER) + + const customerModuleService: + ICustomerModuleService = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + // check if VIP group exists + const vipGroup = await customerModuleService + .listCustomerGroups({ + name: "VIP" + }, { + relations: ["customers"] + }) + + if (!vipGroup.length) { + return + } + + // retrieve the order + const order = await orderModuleService.retrieve(orderId) + + if (!order || + !order.customer_id || + vipGroup[0].customers.find( + (customer) => customer.id === order.customer_id + ) !== undefined) { + return + } + + const [, count] = await orderModuleService.listAndCount({ + customer_id: order.customer_id + }) + + if (count < 20) { + return + } + + // add customer to VIP group + await customerModuleService.addCustomerToGroup({ + customer_group_id: vipGroup[0].id, + customer_id: order.customer_id + }) + } + + export const config: SubscriberConfig = { + event: "order.placed", + } + ``` + +
+ + +--- + +## Marketing Automation + +In your commerce store, you may utilize marketing strategies that encourage customers to make purchases. For example, you send a newsletter when new products are added to your store. + +To do that, create a subscriber that listens to the `product.created`, and send an email to subscribed customers with tools like SendGrid or Mailchimp. + +You can also create a scheduled job that checks whether the number of new products has exceeded a set threshold, then sends out the newsletter. + + + +Scheduled jobs are coming soon. + + + +, + showLinkIcon: false + }, + { + href: "/events-reference", + title: "Events Reference", + text: "Check out triggered events in each commerce module.", + startIcon: , + showLinkIcon: false + }, + { + href: "!docs!/basics/scheduled-jobs", + title: "Scheduled Jobs", + text: "Learn how to create a scheduled job in Medusa.", + startIcon: , + showLinkIcon: false + }, +]} /> + +
+ + For example, create the file `src/subscribers/send-products-newsletter.ts` with the following content: + +export const newsletterHighlights = [ + ["35", "store", "Retrieve the first store in the application."], + ["37", "products", "Retrieve the products created since the last newsletter send date."], + ["43", "", "Check whether more than 10 products have been created before proceeding."], + ["47", "customers", "Retrieve all customers, assuming they're considered subscribed."], + ["49", "create", "Send a notification (newsletter) to each customer using the Notification Module."], + ["52", '"email"', "Send the notification through the email channel."], + ["53", '"newsletter_template"', "Specify the template name in the third-party service (for example, SendGrid)."], + ["55", "products", "Pass the created products to the template."], + ["60", "update", "Update the store's `last_newsletter_send_date` field with the current date."] +] + + ```ts title="src/subscribers/send-products-newsletter.ts" highlights={newsletterHighlights} collapsibleLines="1-16" expandButtonLabel="Show Imports" + import type { + SubscriberArgs, + SubscriberConfig, + } from "@medusajs/medusa" + import { + ModuleRegistrationName + } from "@medusajs/modules-sdk" + import { + NotificationModuleService + } from "@medusajs/notification" + import { + ICustomerModuleService, + IProductModuleService, + IStoreModuleService + } from "@medusajs/types" + + export default async function productCreateHandler({ + data, + container + }: SubscriberArgs<{ id: string }>) { + const productModuleService: IProductModuleService = + container.resolve(ModuleRegistrationName.PRODUCT) + + const storeModuleService: IStoreModuleService = + container.resolve(ModuleRegistrationName.STORE) + + const customerModuleService: ICustomerModuleService = + container.resolve(ModuleRegistrationName.CUSTOMER) + + const notificationModuleService: + NotificationModuleService = container.resolve( + ModuleRegistrationName.NOTIFICATION + ) + + const store = (await storeModuleService.list())[0] + + const products = await productModuleService.list({ + created_at: { + $gt: store.metadata.last_newsletter_send_date + } + }) + + if (products.length < 10) { + return + } + + const customers = await customerModuleService.list() + + await notificationModuleService.create( + customers.map((customer) => ({ + to: customer.email, + channel: "email", + template: "newsletter_template", + data: { + products + } + })) + ) + + await storeModuleService.update(store.id, { + metadata: { + last_newsletter_send_date: (new Date()).toString() + } + }) + } + + export const config: SubscriberConfig = { + event: "product.created", + } + ``` + + In the subscriber function, you: + + 1. Retrieve the first store in our application. + 2. Retrieve products created since the last time a newsletter is sent. The last send date is stored in the store's `metadata` field. + 3. If the count of last created products is less than 10, stop execution. + 4. Retrieve all customers. Here, it's assumed that all customers are considered subscribed for simplicity. + 5. Use the Notification Module to send a notification to the customer. This uses the Notification Provider Module configured for the `email` channel. + 6. Update the store's `last_newsletter_send_date` with the current date. + +
diff --git a/www/apps/resources/app/_recipes/digital-products/page.mdx b/www/apps/resources/app/recipes/digital-products/page.mdx similarity index 51% rename from www/apps/resources/app/_recipes/digital-products/page.mdx rename to www/apps/resources/app/recipes/digital-products/page.mdx index eac7b2a71e..61ca8c106d 100644 --- a/www/apps/resources/app/_recipes/digital-products/page.mdx +++ b/www/apps/resources/app/recipes/digital-products/page.mdx @@ -1,4 +1,4 @@ -import { AcademicCapSolid, BoltSolid } from "@medusajs/icons" +import { AcademicCapSolid, BoltSolid, PuzzleSolid } from "@medusajs/icons" import { LearningPath } from "docs-ui" export const metadata = { @@ -7,47 +7,38 @@ export const metadata = { # {metadata.title} -In this recipe, you'll find resources to guide you in building digital products with Medusa. +This recipe provides the general steps to implement digital products in your Medusa application. ## Overview -Digital products are stored privately using a storage service like S3. When the customer buys them, an email is sent to the customer with instructions to download the product. +Digital products are stored privately using a storage service like S3. When the customer buys this type of product, an email is sent to them where they can download the product. -Medusa doesn't have a built-in concept of a digital product since the focus is standardizing features and implementations, then offering the building blocks that enable you to build your use case. +Medusa doesn't have a built-in concept of a digital product since our focus is standardizing features and implementations, then offering the building blocks that enable you to build your use case. -You can create data models to represent a digital product and link them to existing product-related data models. - -Medusa's plugins also allow you to choose the third-party services that play a part in your digital product implementation. Whether you want to use S3, MinIO, or another service to store your digital products, you must install an existing plugin or create your own. The same applies to sending notifications, such as emails, to customers using notification services. +You can create a Digital Product Module that introduces the concept of a digital product and links it to existing product concepts in the Product Module. --- -## Install a Storage Plugin +## Install a File Provider Module -A storage plugin, which consists of a file service, is used to handle storage functionalities in Medusa, including uploading, retrieving, and downloading files. Having a storage plugin is essential to implement the basic functionalities of digital products. +A file provider module handles storage functionalities in Medusa. This includes uploading, retrieving, and downloading files, among other features. -By default, your Medusa application uses the Local File Storage plugin. This plugin is helpful for development, but it's not recommended for production. +Use a file provider module to manage your stored digital products. -For production, you can use other storage plugins such as the S3 or MinIO plugins. You can also create your own file service, or browse the [Plugins Library](https://medusajs.com/plugins/?filters=Storage&categories=Storage) for storage plugins created by the community. +During development, you can use the Local File Provider Module, which is installed by default in your store. For production, check out available file provider modules or create your own. , + href: "/architectural-modules/file", + title: "File Provider Modules", + text: "Check out available file provider modules.", + startIcon: , showLinkIcon: false }, { - href: "https://medusajs.com/plugins/?filters=Storage&categories=Storage", - title: "Community Storage Plugins", - text: "Check out community storage plugins.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Create a File Service", - text: "Learn how to create a custom file service.", + href: "/references/file-provider-module", + title: "Create a File Provider Module", + text: "Learn how to create a file provider module.", startIcon: , showLinkIcon: false }, @@ -55,458 +46,812 @@ For production, you can use other storage plugins such as the S3 or MinIO plugin --- -## Install a Notification Plugin +## Install a Notification Provider Module -A notification plugin, which consists of a notification service, allows you to send the customer an email or another form of notification with a link to download the file they purchased. +A notification provider module handles sending notifications to users and customers. -Medusa provides official notification plugins, such as the SendGrid plugin. You can also create your own notification service, or browse the [Plugins Library](https://medusajs.com/plugins/?filters=Notification&categories=Notification) for notification plugins created by the community. +For digital products, a notification provider module allows you to send the customer an email or another form of notification with a link to download the file they purchased. + +You can use one of Medusa’s notification provider modules or create your own. + +{/* TODO add links */} , - showLinkIcon: false - }, - { - href: "https://medusajs.com/plugins/?filters=Notification&categories=Notification", - title: "Community Notification Plugins", - text: "Check out available community notification service plugins.", - startIcon: , + href: "/architectural-modules/notification", + title: "Notification Provider Modules", + text: "Check out available official notification provider modules.", + startIcon: , showLinkIcon: false }, { href: "#", - title: "Create a Notification Service", + title: "Create a Notification Provider Module", text: "Learn how to create a custom notification service.", startIcon: , - showLinkIcon: false + showLinkIcon: false, + badge: { + variant: "blue", + children: "Guide Soon" + } }, ]} /> --- +## Create Digital Product Module + +Your custom features and functionalities are implemented inside modules. The module is integrated into the Medusa application without any implications on existing functionalities. + +The module will hold your custom data models and the service implementing digital-product-related features. + +} + showLinkIcon={false} +/> + +
+ + In this section, you’ll create the skeleton of the Digital Product Module. In later sections, you’ll add more resources to it. + + Start by creating the directory `src/modules/digital-product`. + + Then, create the file `src/modules/digital-product/service.ts` with the following content: + + ```ts title="src/modules/digital-product/service.ts" + class DigitalProductModuleService { + // TODO + } + + export default DigitalProductModuleService + ``` + + A module must export a service. So, you implement a dummy service for now. + + Next, create the file `src/modules/digital-product/index.ts` with the following content: + + ```ts title="src/modules/digital-product/index.ts" + import DigitalProductModuleService from "./service" + + export default { + service: DigitalProductModuleService, + } + ``` + + This file holds the definition of the module. + + Finally, add the module to `medusa-config.js` into the `modules` object: + + ```js title="medusa-config.js" + module.exports = defineConfig({ + // ... + modules: { + digitalProductModuleService: { + resolve: "./modules/digital-product" + } + } + }) + ``` + +
+ +--- + ## Create Custom Data Model -To represent a digital product, it's recommended to create a data model that has a relation to the `ProductVariant` data model. The `ProductVariant` data model represents the saleable variant of a `Product`. +A data model represents a table in the database. You can define in your module data models to store data related to your custom features. -For example, if you're selling the Harry Potter movies, you would have a `Product` named “Harry Potter” and, for each movie in the series, a `ProductVariant`. Each `ProductVariant` is associated with the custom data model you create that represents the downloadable movie. +To represent a digital product, it's recommended to create a data model that has a `variant_id` field. In a later section, you’ll learn how to add a relationship to the Product Module’s `ProductVariant` data model. -} + showLinkIcon={false} +/> + +
+ + In this section, you’ll create a `ProductMedia` data model that represents your digital products. + + Before creating the data model, create the file `src/types/digital-product/product-media.ts` that holds common types: + + ```ts title="src/types/digital-product/product-media.ts" + export enum MediaType { + MAIN = "main", + PREVIEW = "preview" + } + ``` + + Then, create the file `src/modules/digital-product/models/product-media.ts` with the following content: + + ```ts title="src/modules/digital-product/models/product-media.ts" + import { Entity, Enum, PrimaryKey, Property } from "@mikro-orm/core" + import { createPsqlIndexStatementHelper, BaseEntity } from "@medusajs/utils" + import { MediaType } from "../../../types/digital-product/product-media" + + const VariantIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_product_media_variant_id", + tableName: "product_media", + columns: "variant_id", + }).MikroORMIndex + + @Entity({ tableName: "product_media" }) + export class ProductMedia extends BaseEntity { + @PrimaryKey({ columnType: "text" }) + id: string + + @Property({ columnType: "text" }) + name: string + + @Enum({ items: ["main", "preview"] }) + type: MediaType + + @Property({ columnType: "text" }) + file_key: string + + @Property({ columnType: "text" }) + mime_type: string + + @VariantIdIndex() + @Property({ columnType: "text" }) + variant_id: string + } + ``` + + The `ProductMedia` data model has fields relevant to digital products. Most importantly, it has a `variant_id` field that will later be used for its relationship with the Product Module. + + To reflect the data model in the database, you must create a migration. + + + + Learn how to generate a migration in [this guide](!docs!/basics/data-models#create-a-migration). + + + + Create the file `src/modules/digital-product/migrations/Migration20240509093233.ts` with the following content: + + ```ts title="src/modules/digital-product/migrations/Migration20240509093233.ts" + import { Migration } from '@mikro-orm/migrations'; + + export class Migration20240509093233 extends Migration { + + async up(): Promise { + this.addSql('create table if not exists "product_media" ("id" text not null, "name" text not null, "type" text check ("type" in (\'main\', \'preview\')) not null, "file_key" text not null, "mime_type" text not null, "variant_id" text not null, constraint "product_media_pkey" primary key ("id"));'); + this.addSql('CREATE INDEX IF NOT EXISTS "IDX_product_media_variant_id" ON "product_media" (variant_id);'); + } + + async down(): Promise { + this.addSql('drop table if exists "product_media" cascade;'); + } + + } + ``` + + To run the migrations, run the `migrations run` command: + + ```bash npm2yarn + npx medusa migrations run + ``` + +
+ +--- + +## Implement Data Management Features + +Your module’s main service holds the management and other related features. Then, in other resources, such as an API route, you can resolve the service from the Medusa container and use its functionalities. + +Medusa facilitates implementing data-management features by providing a service factory. This service factory implements basic data-management features, so you only need to implement features specific to your module. + +, showLinkIcon: false }, { href: "#", - title: "Extend a Data Model", - text: "Learn how to extend a data model.", + title: "Implement Database Operations in a Service", + text: "Learn how to perform database operations like create a record in a service.", startIcon: , showLinkIcon: false }, ]} /> -
- - In this example, you’ll create a `ProductMedia` data model that is associated with the `ProductVariant` data model in a one-to-many relation. - - To do that, create the file `src/models/product-media.ts` with the following content: - - ```ts title="src/models/product-media.ts" badgeLabel="Medusa Application" - import { - BeforeInsert, - Column, - Entity, - } from "typeorm" - import { BaseEntity, ProductVariant } from "@medusajs/medusa" - import { generateEntityId } from "@medusajs/medusa/dist/utils" - +
+ + In this section, you’ll modify the `DigitalProductModuleService` you created earlier to provide data-management functionalities of the `ProductMedia` data model. + + First, change the content of `src/types/digital-product/product-media.ts` to include more common types: + + ```ts title="src/types/digital-product/product-media.ts" + import { ProductVariantDTO } from "@medusajs/types" + export enum MediaType { MAIN = "main", PREVIEW = "preview" } - - @Entity() - export class ProductMedia extends BaseEntity { - @Column({ type: "varchar" }) + + export type ProductMediaDTO = { + id: string name: string - - @Column ({ type: "enum", enum: MediaType, default: "main" }) type: MediaType - - @Column({ type: "varchar" }) file_key: string - - @Column({ type: "varchar" }) mime_type: string - - @Column({ type: "varchar" }) variant_id: string - - variant?: ProductVariant - - @BeforeInsert() - private beforeInsert(): void { - this.id = generateEntityId(this.id, "post") - } + variant?: ProductVariantDTO } - ``` - - The data model has the following attributes: - - - `name`: a string indicating the file name or a name entered by the merchant. - - `type`: an enum value indicating the type of file. If the file’s type is `main`, it means that this is the file that customers download when they purchase the product. If its type is `preview`, it means that the file is only used to preview the product variant to the customer. - - `file_key`: a string that indicates the downloadable file’s key. The key is retrieved by the installed file service, which is covered in the next step, and it’s used later if you want to get a downloadable link or delete the file. - - `variant_id`: a string indicating the ID of the product variant this file is associated with. - - `mime_type`: a string indicating the MIME type of the product variant. - - Next, you need to create a migration script that reflects the changes on the database schema. - - To do that, run the following command to create a migration file: - - ```bash - npx typeorm migration:create src/migrations/ProductMediaCreate - ``` - - This will create a file in the `src/migrations` directory. The file’s name will be of the format `-ProductMediaCreate.ts`, where `` is the time this migration was created. - - In the class defined in the file, change the `up` and `down` method to the following: - - ```ts - export class ProductMediaCreate1693901604934 implements MigrationInterface { - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`CREATE TYPE "public"."product_media_type_enum" AS ENUM('main', 'preview')`) - await queryRunner.query(`CREATE TABLE "product_media" ("id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying NOT NULL, "type" "public"."product_media_type_enum" NOT NULL DEFAULT 'main', "file_key" character varying NOT NULL, "mime_type" character varying NOT NULL, "variant_id" character varying NOT NULL, CONSTRAINT "PK_09d4639de8082a32aa27f3ac9a6" PRIMARY KEY ("id"))`) - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "product_media"`) - await queryRunner.query(`DROP TYPE "public"."product_media_type_enum"`) - } + + export type CreateProductMediaDTO = { + name: string + file_key: string + variant_id: string + type: string + mime_type: string } + + export type UpdateProductMediaDTO = { + name?: string + file_key?: string + variant_id?: string + type?: string + mime_type?: string + } + ``` - - To apply the migrations on your database and create the `product_media` table, run the following commands in the root directory of your Medusa application: - - ```bash npm2yarn - npm run build - npx medusa migrations run + + Then, change the content of `src/modules/digital-product/service.ts` to the following: + + ```ts title="src/modules/digital-product/service.ts" collapsibleLines="1-8" expandButtonLabel="Show Imports" + import { ProductMedia } from "./models/product-media" + import { ModulesSdkUtils } from "@medusajs/utils" + import { ModulesSdkTypes } from "@medusajs/types" + import { + CreateProductMediaDTO, + ProductMediaDTO, + UpdateProductMediaDTO + } from "../../types/digital-product/product-media" + + type InjectedDependencies = { + productMediaService: ModulesSdkTypes.InternalModuleService< + any + > + } + + class DigitalProductModuleService extends ModulesSdkUtils + .abstractModuleServiceFactory< + InjectedDependencies, + ProductMediaDTO, + { + ProductMedia: { + dto: ProductMediaDTO + } + } + >(ProductMedia, [], {}) { + protected readonly productMediaService_: + ModulesSdkTypes.InternalModuleService + + constructor( + { + productMediaService + }: InjectedDependencies, + ) { + // @ts-ignore + super(...arguments) + + this.productMediaService_ = productMediaService + } + + async create( + data: CreateProductMediaDTO, + ): Promise { + const productMedia = await this.productMediaService_ + .create( + data, + ) + + return productMedia + } + + async update( + id: string, + data: UpdateProductMediaDTO, + ): Promise { + const productMedia = await this.productMediaService_ + .update({ + ...data, + id + }) + + return productMedia + } + } + + export default DigitalProductModuleService ``` - - To avoid TypeScript errors while using the `ProductMedia` + + The `DigitalProductModuleService` now extends the generated service returned by the service factory. The generated service already has basic features implemented, such as listing, retrieving, and deleting records. + + You implement in your service the `create` and `update` methods, which aren’t generated automatically. In them, use the `productMediaService` generated for the `ProductMedia` data model in your module's container. It has methods to perform database operations on the data model.
+ +--- + +## Add Relationship to Product Variants + +As mentioned in a previous section, the product media has a `variant_id` that points to the saleable product variant. + +The Product Module implements product variants. However, modules are isolated. So, to reference data models and records from other modules, you can use module relationships. + +The Medusa application resolves module relationships without creating an actual dependency between the modules. This allows you to associate more fields with existing modules while maintaining module isolation. + +} + showLinkIcon={false} +/> + +
+ In this section, you’ll create a relationship from the Digital Product Module to the Product Module. + + To do that, add the following `__joinerConfig` method to the `DigitalProductModuleService`: + + ```ts title="src/modules/digital-product/service.ts" collapsibleLines="1-6" expandButtonLabel="Show Imports" + // other imports... + import { ModuleJoinerConfig } from "@medusajs/types" + import { Modules } from "@medusajs/modules-sdk" + + // ... + + class DigitalProductModuleService extends ModulesSdkUtils + .abstractModuleServiceFactory< + // ... + >( + // ... + ) { + // ... + + __joinerConfig(): ModuleJoinerConfig { + return { + serviceName: "digitalProductModuleService", + primaryKeys: ["id"], + alias: [ + { + name: "product_media", + args: { + entity: ProductMedia.name + } + } + ], + relationships: [ + { + serviceName: Modules.PRODUCT, + alias: "variant", + primaryKey: "id", + foreignKey: "variant_id", + args: { + methodSuffix: "Variants" + } + } + ] + } + } + } + ``` + + This informs the Medusa application that, whenever there’s a `variant_id` field in your module’s data models, look for the record it’s referencing in the `variant` alias (which is the `ProductVariant` data model) of the Product Module. + + + + Learn more about the data returned in the `__joinerConfig` method here. + + + + Next, change the module’s entry in the `modules` object in `medusa-config.js` to the following: + + ```ts title="medusa-config.js" + module.exports = defineConfig({ + // ... + modules: { + digitalProductModuleService: { + resolve: "./modules/digital-product", + definition: { + isQueryable: true + } + } + } + }) + ``` + +
--- ## Add Custom API Routes -You can create API Routes to perform Create, Read, Update, and Delete (CRUD) functionalities on your custom data model. +API routes expose your features to external applications, such as the admin dashboard or the storefront. - +You can create custom API routes that allow merchants to list and create digital products. In these API routes, you resolve the Digital Product Module’s main service to use its data-management features. -The Medusa application provides the necessary API Routes for the actual file upload. So, you don’t need to create API Routes for that. - - - -You can also create custom storefront API Routes that allow you to show information related to the downloadable digital product if this information isn't stored within the `Product` or `ProductVariant` data models. - -Creating an API Route also requires creating a service. You can implement the CRUD functionalities as methods within the service, then access the service in an API Route and use its methods. +To utilize the relationship to the Product Module, you use the remote query to fetch data across modules. , showLinkIcon: false }, { href: "#", - title: "Create an API Route", - text: "Learn how to create an API route.", + title: "Remote Query", + text: "Learn about what the remote query is and how to use it.", startIcon: , showLinkIcon: false }, ]} /> -
+
- This example showcases how you can create the list and create API Routes for digital products. These API Routes are chosen in particular as they’re used in later parts of this recipe. You may follow the same instructions to create other necessary API Routes. - - Before creating the API Routes, you’ll create the `ProductMediaService`. Create the file `src/services/product-media.ts` with the following content: - - ```ts title="src/services/product-media.ts" badgeLabel="Medusa Application" + In this section, you’ll create a List and Create API routes to retrieve and create digital products. + + ### Create API Route + + In the Create API route, you want to create not only the product media but also the associated product variant if no ID is specified. + + You’ll implement this logic in a workflow, then use the workflow in the API route. + + Start by changing the content of `src/types/digital-product/product-media.ts` to include more types: + + ```ts title="src/types/digital-product/product-media.ts" import { - FindConfig, - ProductVariant, - ProductVariantService, - Selector, - TransactionBaseService, - buildQuery, - } from "@medusajs/medusa" - import { ProductMedia } from "../models/product-media" - import { MedusaError } from "@medusajs/utils" - - type InjectedDependencies = { - productVariantService: ProductVariantService + ProductVariantDTO, + CreateProductWorkflowInputDTO + } from "@medusajs/types" + + export enum MediaType { + MAIN = "main", + PREVIEW = "preview" } - - class ProductMediaService extends TransactionBaseService { - protected productVariantService_: ProductVariantService - - constructor(container: InjectedDependencies) { - super(container) - this.productVariantService_ = - container.productVariantService + + export type ProductMediaDTO = { + id: string + name: string + type: MediaType + file_key: string + mime_type: string + variant_id: string + variant?: ProductVariantDTO + } + + export type CreateProductMediaDTO = { + name: string + file_key: string + type: MediaType + mime_type: string + variant_id?: string + } + + export type UpdateProductMediaDTO = { + name?: string + file_key?: string + variant_id?: string + type?: MediaType + mime_type?: string + } + + export type FilterableProductMediaProps = { + name?: string[] + file_key?: string[] + variant_id?: string[] + type?: MediaType[] + mime_type?: string[] + } + + export type CreateProductMediaWorkflowInput = + CreateProductMediaDTO & { + product?: CreateProductWorkflowInputDTO } - private checkVariantInRelations( - relations: string[] - ): [string[], boolean] { - const variantsRelationIndex = relations.indexOf("variant") - const isVariantsEnabled = variantsRelationIndex !== -1 - if (isVariantsEnabled) { - relations.splice(variantsRelationIndex, 1) - } - - return [relations, isVariantsEnabled] - } - - async listAndCount( - selector?: Selector, - config: FindConfig = { - skip: 0, - take: 20, - relations: [], - } - ): Promise<[ProductMedia[], number]> { - const productMediaRepo = this.activeManager_.getRepository( - ProductMedia - ) - - const [ - relations, - isVariantsEnabled, - ] = this.checkVariantInRelations( - config.relations || [] - ) - - config.relations = relations - - console.log(selector, config.relations) - - const query = buildQuery(selector, config) - - const [ - productMedias, - count, - ] = await productMediaRepo.findAndCount(query) - - if (isVariantsEnabled) { - // retrieve product variants - await Promise.all(productMedias.map( - async (media, index) => { - productMedias[index].variant = - await this.retrieveVariantByMedia(media) - })) - } - - return [productMedias, count] - } - - async list( - selector?: Selector, - config: FindConfig = { - skip: 0, - take: 20, - relations: [], - } - ): Promise { - const [productMedias] = await this.listAndCount( - selector, config - ) - - return productMedias - } - - async retrieve( - id: string, - config?: FindConfig - ): Promise { - const productMediaRepo = this.activeManager_.getRepository( - ProductMedia - ) - - const query = buildQuery({ - id, - }, config) - - const productMedia = await productMediaRepo.findOne(query) - - if (!productMedia) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - "ProductMedia was not found" - ) - } - - if (config.relations.includes("variant")) { - productMedia.variant = await this.retrieveVariantByMedia( - productMedia - ) - } - - return productMedia - } - - async retrieveVariantByMedia(productMedia: ProductMedia) { - return await this.productVariantService_.retrieve( - productMedia.variant_id, - { - relations: ["product"], + ``` + + Then, create the file `src/workflows/digital-product/create.ts` with the following content: + + ```ts title="src/workflows/digital-product/create.ts" collapsibleLines="1-20" expandButtonLabel="Show Imports" + import { + createWorkflow, + WorkflowData, + createStep, + StepResponse + } from "@medusajs/workflows-sdk" + import { createProductsWorkflow } from "@medusajs/core-flows" + import { + CreateProductMediaDTO, + CreateProductMediaWorkflowInput, + ProductMediaDTO + } from "../../types/digital-product/product-media" + import DigitalProductModuleService from + "../../modules/digital-product/service" + import { RemoteQueryFunction } from "@medusajs/modules-sdk" + import { + ContainerRegistrationKeys, + remoteQueryObjectFromString + } from "@medusajs/utils" + + const tryToCreateProductVariantStep = createStep( + "try-to-create-product-variant-step", + async (input: CreateProductMediaWorkflowInput, { container }) => { + if (input.product && !input.variant_id) { + const { result, errors } = await createProductsWorkflow(container) + .run({ + input: { + products: [input.product] + }, + throwOnError: false + }) + + if (errors.length) { + throw errors[0].error } - ) - } - - async retrieveMediasByVariant( - productVariant: ProductVariant - ): Promise { - const productMediaRepo = this.activeManager_.getRepository( - ProductMedia - ) - - const query = buildQuery({ - variant_id: productVariant.id, - }) - - const productMedias = await productMediaRepo.find(query) - - return productMedias - } - - async create( - data: Pick< - ProductMedia, - "name" | "file_key" | "variant_id" | "type" | "mime_type" - > - ): Promise { - return this.atomicPhase_(async (manager) => { - const productMediaRepo = manager.getRepository( - ProductMedia - ) - const productMedia = productMediaRepo.create(data) - const result = await productMediaRepo.save(productMedia) - - return result - }) - } - - async update( - id: string, - data: Omit, "id"> - ): Promise { - return await this.atomicPhase_(async (manager) => { - const productMediaRepo = manager.getRepository( - ProductMedia - ) - const productMedia = await this.retrieve(id) - - Object.assign(productMedia, data) - - return await productMediaRepo.save(productMedia) - }) - } - - async delete(id: string): Promise { - return await this.atomicPhase_(async (manager) => { - const productMediaRepo = manager.getRepository( - ProductMedia - ) - const productMedia = await this.retrieve(id) - - await productMediaRepo.remove([productMedia]) - }) - } - } - - export default ProductMediaService - ``` - - This service implements the necessary methods to perform the basic CRUD operations. You can add any other method if necessary. - - You can now create the API Routes. Create the file `src/api/admin/product-media/route.ts` with the following content: - - ```ts title="src/api/admin/product-media/route.ts" badgeLabel="Medusa Application" - import type { - MedusaRequest, - MedusaResponse, - } from "@medusajs/medusa" - import ProductMediaService - from "../../../services/product-media" - import { MediaType } from "../../../models/product-media" - - export const GET = async ( - req: MedusaRequest, - res: MedusaResponse - ) => { - const productMediaService = req.scope.resolve< - ProductMediaService - >("productMediaService") - // omitting pagination for simplicity - const [productMedias, count] = await productMediaService - .listAndCount({ - type: MediaType.MAIN, - }, { - relations: ["variant"], + + input.variant_id = result[0].variants[0].id + + delete input.product } - ) - - res.json({ - product_medias: productMedias, - count, - }) + + return new StepResponse(input) + } + ) + + const createProductMediaStep = createStep( + "create-product-media-step", + async (input: CreateProductMediaDTO, { container }) => { + const digitalProductModuleService: + DigitalProductModuleService = container.resolve( + "digitalProductModuleService" + ) + + const productMedia = await digitalProductModuleService.create( + input + ) + + return new StepResponse(productMedia) + } + ) + + const retrieveProductMediaWithVariant = createStep( + "retrieve-product-media-with-variant-step", + async (input: ProductMediaDTO, { container }) => { + const remoteQuery: RemoteQueryFunction = container.resolve( + ContainerRegistrationKeys.REMOTE_QUERY + ) + + const query = remoteQueryObjectFromString({ + entryPoint: "product_media", + fields: [ + "id", + "name", + "type", + "file_key", + "mime_type", + "variant.*" + ], + variables: { + filters: { + id: input.id + } + } + }) + + const result = await remoteQuery(query) + + return new StepResponse(result[0]) + } + ) + + type WorkflowInput = { + data: CreateProductMediaWorkflowInput } - - export const POST = async ( - req: MedusaRequest, + + export const createProductMediaWorkflow = createWorkflow( + "create-product-media-workflow", + function (input: WorkflowData) { + // create the product variant before creating the media + // if variant_id isn't passed + const normalizedInput = tryToCreateProductVariantStep(input.data) + + const productMedia = createProductMediaStep(normalizedInput) + + return retrieveProductMediaWithVariant(productMedia) + } + ) + ``` + + This workflow has three steps: + + 1. If a `variant_id` field isn’t passed and a `product` field is passed, create the product using Medusa’s `createProductsWorkflow` and set the ID of the variant in the `variant_id` field. + 2. Use the `DigitalProductModuleService` to create the product media. + 3. Use the remote query to retrieve the product media along with the variant it references. + + Finally, create the `src/api/admin/digital-products/route.ts` file with the following content: + + ```ts title="src/api/admin/digital-products/route.ts" collapsibleLines="1-12" expandButtonLabel="Show Imports" + import { + MedusaRequest, + MedusaResponse + } from "@medusajs/medusa" + import { MedusaError } from "@medusajs/utils" + import { + CreateProductMediaWorkflowInput + } from "../../../types/digital-product/product-media" + import { + createProductMediaWorkflow + } from "../../../workflows/digital-product/create" + + type CreateProductMediaReq = CreateProductMediaWorkflowInput + + export async function POST( + req: MedusaRequest, res: MedusaResponse - ) => { + ) { // validation omitted for simplicity - const { - variant_id, - file_key, - type = "main", - name, - mime_type, - } = req.body - - const productMediaService = req.scope.resolve< - ProductMediaService - >("productMediaService") - const productMedia = await productMediaService.create({ - variant_id, - file_key, - type, - name, - mime_type, - }) - + const { + result, + errors + } = await createProductMediaWorkflow(req.scope) + .run({ + input: { + data: { + ...req.body + } + }, + throwOnError: false + }) + + if (errors.length) { + throw new MedusaError( + MedusaError.Types.DB_ERROR, + errors[0].error + ) + } + res.json({ - product_media: productMedia, + product_media: result }) } ``` - - This creates two API Routes: - - - a `GET` API Route at `/admin/product-media` that retrieves a list of product media records that have the type `main`. It also retrieves the relations `variant` and `variant.product` to access the product media’s variant and main product. - - a `POST` API Route at `/admin/product-media` that accepts the necessary data to create a product media, then returns the created product media. + + This adds a `POST` API route at `/admin/digital-products` that executes the `createProductMediaWorkflow` workflow. + + To test it out, start the Medusa application: + + ```bash npm2yarn + npm run dev + ``` + + Next, authenticate as an admin user as explained in the [API Reference] + + Then, upload a file using the Upload API route: + + ```bash + curl -X POST 'http://localhost:9000/admin/uploads' \ + -H 'Authorization: Bearer {bearer_token}' \ + --form 'files=@"/path/to/file"' + ``` + + Make sure to replace `/path/to/file` with the path to the file to upload. Copy the `id` field’s value as you’ll use it as the `file_key`'s value when creating the digital product. + + Finally, send a request to the API route you created: + + ```bash + curl -X POST 'localhost:9000/admin/digital-products' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer {bearer_token}' \ + --data '{ + "name": "Harry Potter", + "file_key": "file.png", + "type": "main", + "mime_type": "image/png", + "product": { + "title": "Harry Potter Books", + "variants": [ + { + "title": "Harry Potter 1" + } + ] + } + }' + ``` + + This creates a product and a variant, and a product media that references the created variant. + + You’ll receive a response similar to the following: + + ```json + { + "product_media": { + "id": "promed_01HXEFRMS79293ASYVN8YY9Y0J", + "name": "Harry Potter", + "type": "main", + "file_key": "file.png", + "mime_type": "image/png", + "variant_id": "variant_01HXEFRMR3B09EZJX23P1DMYFQ", + "variant": { + "id": "variant_01HXEFRMR3B09EZJX23P1DMYFQ", + "title": "Harry Potter 1", + // ... + } + } + } + ``` + + ### List API Route + + Next, you’ll create the List API route that returns a list of digital products. + + To do that, add the following to `src/api/admin/digital-products/route.ts`: + + ```ts title="src/api/admin/digital-products/route.ts" collapsibleLines="1-9" expandButtonLabel="Show Imports" + // other imports... + import { RemoteQueryFunction } from "@medusajs/modules-sdk" + import { + ContainerRegistrationKeys, + remoteQueryObjectFromString + } from "@medusajs/utils" + + // ... + + export async function GET( + req: MedusaRequest, + res: MedusaResponse + ) { + const remoteQuery: RemoteQueryFunction = req.scope.resolve( + ContainerRegistrationKeys.REMOTE_QUERY + ) + + const query = remoteQueryObjectFromString({ + entryPoint: "product_media", + fields: [ + "id", + "name", + "type", + "file_key", + "mime_type", + "variant.*", + "variant.product.*" + ], + }) + + const result = await remoteQuery(query) + + res.json({ + product_medias: result + }) + } + ``` + + This creates a new `GET` API route at `/admin/digital-products` that retrieves the list of digital products and their associated variants. + + To test it out, send a request to the API route while your Medusa application is running: + + ```bash apiTesting testApiUrl="http://localhost:9000/admin/digital-products" testApiMethod="GET" + curl 'localhost:9000/admin/digital-products' \ + -H 'Authorization: Bearer {bearer_token}' \ + ``` + + You’ll receive a list of digital products.
@@ -515,9 +860,9 @@ Creating an API Route also requires creating a service. You can implement the CR ## Customize Admin Dashboard -The Medusa Admin dashboard provides merchants with an easy-to-use interface to manage their store's data and settings. It's also extendable, so you can add widgets, pages, and setting pages. +You can extend the Medusa Admin to provide merchants with an interface to manage digital products. You can inject widgets into existing pages or create new pages. -To add an interface that allows the admin user to upload digital products, you can create custom widgets or pages that use the API routes you created or the [Protected Files Upload API Route](https://docs.medusajs.com/api/admin#uploads_postuploadsprotected). +In your customizations, you send requests to the API routes you created to create and list digital products. , showLinkIcon: false }, - { - href: "https://docs.medusajs.com/api/admin#uploads_postuploadsprotected", - title: "Protected Files Upload API Route", - text: "Learn about the expected request body and response.", - startIcon: , - showLinkIcon: false - }, ]} />
In this example, you’ll add a single page that lists the digital products and allows you to create a new one. The implementation will be minimal for the purpose of simplicity, so you can elaborate on it based on your use case. - - Before starting off, make sure to install the necessary dependencies in your Medusa application project: - ```bash npm2yarn - npm install medusa-react @tanstack/react-query@4.22 @medusajs/ui - ``` + To create the UI route, create the file `src/admin/routes/product-media/page.tsx` with the following content: - This installs the necessary packages to use the Medusa React client and the [Medusa UI package](https://docs.medusajs.com/ui). - - You also need to create types for the expected requests and responses of the API Routes you created. This is helpful when using Medusa React’s custom hooks. To do that, create the file `src/types/product-media.ts` with the following content: - - ```ts title="src/types/product-media.ts" badgeLabel="Medusa Application" + ```tsx title="src/admin/routes/product-media/page.tsx" badgeLabel="Medusa Application" collapsibleLines="1-13" expandButtonLabel="Show Imports" + import { defineRouteConfig } from "@medusajs/admin-shared"; + import { useEffect, useState } from "react"; import { - MediaType, - ProductMedia, - } from "../models/product-media" - - export type ListProductMediasRequest = { - // no expected parameters - }; - - export type ListProductMediasResponse = { - product_medias: ProductMedia[] - count: number - }; - - export type CreateProductMediaRequest = { - variant_id: string - name: string - file_key: string - type?: MediaType - mime_type: string - }; - - export type CreateProductMediaResponse = { - product_media: ProductMedia - }; - ``` - - You can now create your admin UI route. To do that, create the file `src/admin/routes/product-media/page.tsx` with the following content: - - ```tsx title="src/admin/routes/product-media/page.tsx" badgeLabel="Medusa Application" - import { RouteConfig } from "@medusajs/admin" - import { DocumentText } from "@medusajs/icons" - import { useAdminCustomQuery } from "medusa-react" - import { - ListProductMediasRequest, - ListProductMediasResponse, - } from "../../../types/product-media" - import { - Button, Container, - Drawer, Heading, Table, } from "@medusajs/ui" + import { PhotoSolid } from "@medusajs/icons" + import { ProductMediaDTO } from "../../../types/digital-product/product-media" import { Link } from "react-router-dom" - import { RouteProps } from "@medusajs/admin-ui" import ProductMediaCreateForm from "../../components/product-media/CreateForm" - const ProductMediaListPage = (props: RouteProps) => { - const { data, isLoading } = useAdminCustomQuery< - ListProductMediasRequest, - ListProductMediasResponse - >( - "/product-media", - ["product-media"] - ) + const ProductMediaListPage = () => { + const [loading, setLoading] = useState(true) + const [productMedias, setProductMedias] = useState([]) + + useEffect(() => { + if (!loading) { + return + } + + fetch(`/admin/digital-products`, { + credentials: "include", + }) + .then((response) => response.json()) + .then(({ product_medias }) => { + setProductMedias(product_medias) + setLoading(false) + }) + }, [loading]) return (
Digital Products - - - - - - - - Create Digital Product - - - - - - - + setLoading(true)} />
- {isLoading &&
Loading...
} - {data && !data.product_medias.length && ( + {loading &&
Loading...
} + {!loading && !productMedias.length && (
No Digital Products
)} - {data && data.product_medias.length > 0 && ( + {!loading && productMedias.length > 0 && ( @@ -653,23 +943,23 @@ To add an interface that allows the admin user to upload digital products, you c - {data.product_medias.map((product_media) => ( + {productMedias.map((product_media) => ( - {product_media.variant.product.title} + {product_media.variant?.product.title} - {product_media.variant.title} + {product_media.variant?.title} {product_media.file_key} - View Product - + } ))} @@ -680,47 +970,38 @@ To add an interface that allows the admin user to upload digital products, you c ) } - export const config: RouteConfig = { - link: { + export const config = defineRouteConfig({ label: "Digital Products", - icon: DocumentText, - }, - } + icon: PhotoSolid, + }) export default ProductMediaListPage ``` - This UI route will show under the sidebar with the label “Digital Products”. In the page, you use the `useAdminCustomQuery` hook imported from `medusa-react` to send a request to your custom “list digital products” API Route. + This UI route will show under the sidebar with the label “Digital Products”. In the page, you use the `/admin/digital-products` API route to retrieve and display the product medias. - In the page, you’ll show the list of digital products in a table, if there are any. You’ll also show a button that opens a drawer to the side of the page. + You also render a `ProductMediaCreateForm` component that implements the Create Digital Product form. - In the drawer, you show the Create Digital Product form. To create this form, create the file `src/admin/components/product-media/CreateForm/index.tsx` with the following content: + To create this form, create the file `src/admin/components/product-media/CreateForm/index.tsx` with the following content: - ```tsx title="src/admin/components/product-media/CreateForm/index.tsx" badgeLabel="Medusa Application" + ```tsx title="src/admin/components/product-media/CreateForm/index.tsx" badgeLabel="Medusa Application" collapsibleLines="1-11" expandButtonLabel="Show Imports" import { useState } from "react" - import { MediaType } from "../../../../models/product-media" - import { - useAdminCreateProduct, - useAdminCustomPost, - useAdminUploadProtectedFile, - } from "medusa-react" - import { - CreateProductMediaRequest, - CreateProductMediaResponse, - } from "../../../../types/product-media" + import { redirect } from "react-router-dom" import { Button, Container, Input, Label, Select, + Drawer } from "@medusajs/ui" - import { RouteProps } from "@medusajs/admin-ui" - import { useNavigate } from "react-router-dom" - const ProductMediaCreateForm = ({ - notify, - }: RouteProps) => { + type Props = { + onCreate: () => void + } + + const ProductMediaCreateForm = ({ onCreate }: Props) => { + const [open, setOpen] = useState(false) const [productName, setProductName] = useState("") const [ productVariantName, @@ -729,149 +1010,142 @@ To add an interface that allows the admin user to upload digital products, you c const [name, setName] = useState("") const [type, setType] = useState("main") const [file, setFile] = useState() + const [loading, setLoading] = useState(false) - const createProduct = useAdminCreateProduct() - const uploadFile = useAdminUploadProtectedFile() - const { - mutate: createDigitalProduct, - isLoading, - } = useAdminCustomPost< - CreateProductMediaRequest, - CreateProductMediaResponse - >( - "/product-media", - ["product-media"] - ) - - const navigate = useNavigate() - - const handleSubmit = ( - e: React.FormEvent - ) => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault() - - createProduct.mutate({ - title: productName, - is_giftcard: false, - discountable: false, - options: [ - { - title: "Digital Product", + setLoading(true) + + const formData = new FormData() + formData.append("files", file); + + // upload file + fetch(`/admin/uploads`, { + method: "POST", + credentials: "include", + body: formData + }) + .then((res) => res.json()) + .then(({ files }) => { + // create digital product + fetch(`/admin/digital-products`, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json" }, - ], - variants: [ - { - title: productVariantName, - options: [ - { - value: name, // can also be the file name - }, - ], - // for simplicity, prices are omitted from form. - // Those can be edited from the product's page. - prices: [], - }, - ], - }, { - onSuccess: ({ product }) => { - // upload file - uploadFile.mutate(file, { - onSuccess: ({ uploads }) => { - if (!("key" in uploads[0])) { - return - } - // create the digital product - createDigitalProduct({ - variant_id: product.variants[0].id, - name, - file_key: uploads[0].key as string, - type: type as MediaType, - mime_type: file.type, - }, { - onSuccess: () => { - notify.success( - "Success", - "Digital Product Created Successfully" - ) - navigate("/a/product-media") - }, - }) - }, + body: JSON.stringify({ + name, + file_key: files[0].id, + type, + mime_type: file.type, + product: { + title: productName, + variants: [ + { + title: name + } + ] + } }) - }, + }) + .then((res) => res.json()) + .then(() => { + setOpen(false) + setLoading(false) + onCreate() + }) + .catch((e) => { + console.error(e) + setLoading(false) + }) + }) + .catch((e) => { + console.error(e) + setLoading(false) }) } return ( - -
-
- - setProductName(e.target.value)} - /> -
-
- - - setProductVariantName(e.target.value) - } - /> -
-
- - setName(e.target.value)} - /> -
-
- - -
-
- - setFile(e.target.files[0])} - /> -
- - -
+ + + + + + + + Create Digital Product + + + + +
+
+ + setProductName(e.target.value)} + /> +
+
+ + + setProductVariantName(e.target.value) + } + /> +
+
+ + setName(e.target.value)} + /> +
+
+ + +
+
+ + setFile(e.target.files[0])} + /> +
+ + +
+
+
+
) } @@ -880,17 +1154,20 @@ To add an interface that allows the admin user to upload digital products, you c In this component, you create a form that accepts basic information needed to create the digital product. This form only accepts one file for one variant for simplicity purposes. You can expand on this based on your use case. - Notice that an alternative approach would be to inject a widget to the Product Details page and allow users to upload the files from there. It depends on whether you’re only supporting Digital Products or you want the distinction between them, as done here. + - When the user submits the form, you first create a product with a variant. Then, you upload the file using the [Upload Protected File API Route](https://docs.medusajs.com/api/admin#uploads_postuploadsprotected). Finally, you create the digital product using the custom API Route you created. + An alternative approach would be to inject a widget to the Product Details page and allow users to upload the files from there. It depends on whether you’re only supporting Digital Products or you want the distinction between them, as done here. - The product’s details can still be edited from the same Products interface, similar to regular products. You can edit its price, add more variants, and more. + - To test it out, build changes and run the `develop` command: + When the user submits the form, you first upload the file using the [Upload Protected File API Route](!api!/admin#uploads_postuploads). Then, you create the digital product using the custom API Route you created. + + The product’s details can still be edited from the product's page, similar to regular products. You can edit its price, add more variants, and more. + + To test it out, run the `dev` command: ```bash npm2yarn - npm run build - npx medusa develop + npm run dev ``` If you open the admin now, you’ll find a new Digital Products item in the sidebar. You can try adding Digital Products and viewing them. @@ -903,25 +1180,25 @@ To add an interface that allows the admin user to upload digital products, you c When a customer purchases a digital product, they should receive a link to download it. -To do that, create a subscriber that listens to the `order.placed` event. Then, in the subscriber handler function, check for the digital products in the order and obtain the download URLs using the `FileService`'s `getPresignedDownloadUrl` method. +You can create a subscriber that listens to the `order.placed` event. In the subscriber, you check for the digital products in the order and obtain the download URLs using the file provider module’s `getPresignedDownloadUrl` method. -Following this approach assumes the file service you're using handles creating secure presigned URLs with an expiration mechanism. Alternatively, you can create a new service that handles creating and validating tokens, and an API Route that receives that token to allow customers to download the file. +Following this approach assumes the file provider module you're using handles creating secure pre-signed URLs with an expiration mechanism. Alternatively, create a token on purchase using the API Key Module and create an API route that validates that token. -Finally, you send a notification, such as an email, to the customer using the notification service or provider of your choice. That notification would hold the download links to the products they purchased. +In the subscriber, you can send a notification, such as an email, to the customer using the notification provider module of your choice. That notification would hold the download links to the products the customer purchased. } showLinkIcon={false} /> -
+
@@ -929,72 +1206,74 @@ Finally, you send a notification, such as an email, to the customer using the no - Here’s an example of a subscriber that retrieves the download links and sends them to the customer using the SendGrid plugin: + Here’s an example of a subscriber that retrieves the download links and sends them to the customer using the installed notification provider module: - ```ts title="src/subscribers/handle-order.ts" badgeLabel="Medusa Application" + ```ts title="src/subscribers/handle-order.ts" badgeLabel="Medusa Application" collapsibleLines="1-15" expandButtonLabel="Show Imports" import { type SubscriberConfig, type SubscriberArgs, - OrderService, - AbstractFileService, } from "@medusajs/medusa" - import ProductMediaService from "../services/product-media" + import { + IOrderModuleService, + IFileModuleService, + INotificationModuleService + } from "@medusajs/types" + import { + ModuleRegistrationName, + } from "@medusajs/modules-sdk" + import DigitalProductModuleService + from "../modules/digital-product/service" export default async function handleOrderPlaced({ - data, eventName, container, pluginOptions, - }: SubscriberArgs>) { - const orderService: OrderService = container.resolve( - "orderService" - ) - const fileService: AbstractFileService = container.resolve( - "fileService" - ) - const productMediaService: ProductMediaService = + data, container, + }: SubscriberArgs<{ id: string }>) { + const orderModuleService: IOrderModuleService = container.resolve( - "productMediaService" + ModuleRegistrationName.ORDER + ) + const fileModuleService: IFileModuleService = + container.resolve( + ModuleRegistrationName.FILE + ) + const notificationModuleService: INotificationModuleService = + container.resolve(ModuleRegistrationName.NOTIFICATION) + const digitalProductModuleService: + DigitalProductModuleService = container.resolve( + "digitalProductModuleService" ) - const sendgridService = container.resolve("sendgridService") - const order = await orderService.retrieve(data.id, { - relations: [ - "items", - "items.variant", - ], + const orderId = "data" in data ? data.data.id : data.id + + const order = await orderModuleService.retrieve(orderId, { + relations: ["items"] }) // find product medias in the order const urls = [] for (const item of order.items) { - const productMedias = await productMediaService - .retrieveMediasByVariant(item.variant) - if (!productMedias.length) { - return - } - - await Promise.all([ - productMedias.forEach( - async (productMedia) => { + const productMedias = await digitalProductModuleService + .list({ + variant_id: [item.variant_id] + }) + + const downloadUrls = await Promise.all( + productMedias.map(async (productMedia) => { + // get the download URL from the file service - const downloadUrl = await - fileService.getPresignedDownloadUrl({ - fileKey: productMedia.file_key, - isPrivate: true, - }) + return (await fileModuleService.retrieve( + productMedia.file_key + )).url + }) + ) - urls.push(downloadUrl) - }), - ]) - } - - if (!urls.length) { - return + urls.push(...downloadUrls) } - sendgridService.sendEmail({ - templateId: "digital-download", - from: "hello@medusajs.com", + notificationModuleService.create({ to: order.email, - dynamic_template_data: { + template: "digital-download", + channel: "email", + data: { // any data necessary for your template... digital_download_urls: urls, }, @@ -1002,16 +1281,11 @@ Finally, you send a notification, such as an email, to the customer using the no } export const config: SubscriberConfig = { - event: OrderService.Events.PLACED, - context: { - subscriberId: "order-placed-handler", - }, + event: "order.placed", } ``` - - Notice that regardless of what file service you have installed, you can access it using dependency injection under the name `fileService`. - - The `handleOrderPlaced` method retrieves the order, loops over its items to find digital products and retrieve their download links, then uses SendGrid to send the email, passing it the urls as a data payload. You can customize the sent data based on your SendGrid template and your use case. + + The `handleOrderPlaced` subscriber function retrieves the order, loops over its items to find digital products and retrieve their download links, then uses the installed notification provider module to send the email, passing the URLs as a data payload. You can customize the sent data based on your template and your use case.
@@ -1019,68 +1293,92 @@ Finally, you send a notification, such as an email, to the customer using the no ## Customize or Build Storefront -Customers use your storefront to browse your digital products and purchase them. You can also provide other helpful features such as previewing the digital product before purchase. +Customers use your storefront to browse your digital products and purchase them. You can also provide other helpful features, such as previewing the digital product before purchase. -Medusa provides a Next.js Starter storefront with standard commerce features including listing products, placing orders, and managing accounts. You can customize the storefront and cater its functionalities to support digital products. +Medusa provides a Next.js storefront with standard commerce features including listing products, placing orders, and managing accounts. You can customize the storefront and cater its functionalities to support digital products. -Alternatively, follow the Build a Storefront roadmap to build a storefront with your preferred technology stack. +Alternatively, you can build the storefront with your preferred tech stack. This guide has tips on building storefronts. -The rest of this section provides some guidelines into how to customize the Next.js Starter to support digital products. +The rest of this section provides some guidelines on how to customize the Next.js storefront to support digital products. + + + +While our team ensures to maintain this section with the changes in the Next.js storefront, some changes may cause the code in this section to be outdated. If you encounter outdated code snippets, please submit [an issue](https://github.com/medusajs/medusa/issues/new?assignees=&labels=type:+docs&projects=&template=docs.yml). + + ### Preview Digital Product -In the product detail's page, add a button that allows customers to download a preview of the digital product. +On the product detail page, you can add a button that allows customers to download a preview of the digital product. -To implement this, create a storefront API Route that fetches the digital product, then customize the Next.js Starter to show the preview button if a product is digital. +To implement this, create a storefront API Route that allows you to fetch the digital product, then customize the Next.js storefront to show the preview button if a product is digital.
- Before you customize the Next.js Starter, you need to create a Store API Route. + Start by creating a store API Route in your Medusa application that lists digital products. + + Create the file `src/api/store/digital-products/route.ts` with the following content: - Create the file `src/api/store/product-media/route.ts` with the following content: - - ```ts title="src/api/store/product-media/route.ts" badgeLabel="Medusa Application" + ```ts title="src/api/store/product-media/route.ts" badgeLabel="Medusa Application" collapsibleLines="1-13" expandButtonLabel="Show Imports" import type { MedusaRequest, MedusaResponse, } from "@medusajs/medusa" - import ProductMediaService - from "../../../services/product-media" - import { MediaType } from "../../../models/product-media" - + import { + MediaType + } from "../../../types/digital-product/product-media" + import { RemoteQueryFunction } from "@medusajs/modules-sdk" + import { + ContainerRegistrationKeys, + remoteQueryObjectFromString + } from "@medusajs/utils" + export const GET = async ( req: MedusaRequest, res: MedusaResponse ) => { - const productMediaService = req.scope.resolve< - ProductMediaService - >("productMediaService") - // omitting pagination for simplicity - const [ - productMedias, - count, - ] = await productMediaService.listAndCount({ - type: MediaType.PREVIEW, - ...(req.query), - }, { - relations: ["variant"], + const remoteQuery: RemoteQueryFunction = req.scope.resolve( + ContainerRegistrationKeys.REMOTE_QUERY + ) + const query = remoteQueryObjectFromString({ + entryPoint: "product_media", + fields: [ + "id", + "name", + "file_key", + "mime_type", + "variant.*" + ], + variables: { + filters: { + type: MediaType.PREVIEW + }, + // omitting pagination for simplicity + skip: 0 + } }) - + + const { + rows, + metadata: { count } + } = await remoteQuery(query) + res.json({ - product_medias: productMedias, + product_medias: rows, count, }) } ``` - This adds a store API Route that returns a list of product medias of type `preview`. It also allows you to pass query parameters to filter the returned products medias. + This adds a `GET` API route at `/store/product-media` that retrieves product media of type `preview` with their variant. + + {/* TODO check rest of steps */} Now, you can customize the Next.js Starter to show the preview button. First, if you're using TypeScript for your development, create the file `src/types/product-media.ts` with the following content: ```ts title="src/types/product-media.ts" badgeLabel="Storefront" badgeColor="blue" - import { Product } from "@medusajs/medusa" import { ProductVariant } from "@medusajs/product" @@ -1109,11 +1407,12 @@ To implement this, create a storefront API Route that fetches the digital produc export type DigitalProductVariant = ProductVariant & { product_medias?: ProductMedia } + ``` Then, add in `src/lib/data/index.ts` a new function that retrieves the product media of the product variant being viewed: - ```ts title="src/lib/data/index.ts" badgeLabel="Storefront" badgeColor="blue" + ```ts title="src/lib/data/index.ts" badgeLabel="Storefront" badgeColor="blue" collapsibleLines="1-7" expandButtonLabel="Show Imports" import { ProductMedia, } from "types/product-media" @@ -1224,14 +1523,13 @@ To implement this, create a storefront API Route that fetches the digital produc Finally, add the button as one of the product actions defined in `src/modules/products/components/product-actions/index.tsx`. These are the actions shown to the customer in the product details page: - ```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" + ```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" collapsibleLines="1-6" expandButtonLabel="Show Imports" // other imports... import { useState, useEffect } from "react" import ProductMediaPreview from "../product-media-preview" import { getProductMediaPreviewByVariant } from "@lib/data" import { ProductMedia } from "types/product-media" - const ProductActions: React.FC = ({ product, }) => { @@ -1276,10 +1574,12 @@ To implement this, create a storefront API Route that fetches the digital produc In the product details page, additional information related to the product and its shipping details are shown at the bottom right side. -You can change this section to show information relevant to the product. For example, how many pages are in an e-book, or how the e-book is delivered to the customer. +You can change this section to show information relevant to the product. For example, how many pages are in an e-book or how the e-book will be delivered to the customer.
+ {/* TODO check steps */} + In this example, you'll change the content of the Product Information and Shipping & Returns tabs to show information relevant to the digital product. The Product Information tab will include custom information relevant to digital products, and the Shipping & Returns tab will be changed to "E-book delivery" and will hold details about how the e-book will be delivered to the customer. One way to store custom information relevant to the digital product is using the `metadata` field. For example, to store the number of pages of an e-book, set the `metadata` field to the following: @@ -1398,13 +1698,15 @@ You can change this section to show information relevant to the product. For exa ### Change Shipping Form in Checkout -When a customer purchases a digital product, the shipping form shown during checkout is not relevant. So, you can change its content to instead only ask for the customer's name and email. +When a customer purchases a digital product, the shipping form shown during checkout is irrelevant. So, you can change its content to instead only ask for the customer's name and email.
+ {/* TODO check steps */} + The checkout flow is managed by a checkout context defined in `src/lib/context/checkout-context.tsx`. Change the content of the file to the following: - ```tsx title="src/lib/context/checkout-context.tsx" badgeLabel="Storefront" badgeColor="blue" + ```tsx title="src/lib/context/checkout-context.tsx" badgeLabel="Storefront" badgeColor="blue" collapsibleLines="1-27" expandButtonLabel="Show Imports" "use client" import { medusaClient } from "@lib/config" @@ -1829,7 +2131,7 @@ When a customer purchases a digital product, the shipping form shown during chec Next, change the content of `src/modules/checkout/components/shipping-address/index.tsx` to remove the unnecessary address fields: - ```tsx title="src/modules/checkout/components/shipping-address/index.tsx" badgeLabel="Storefront" badgeColor="blue" + ```tsx title="src/modules/checkout/components/shipping-address/index.tsx" badgeLabel="Storefront" badgeColor="blue" collapsibleLines="1-10" expandButtonLabel="Show Imports" import { CheckoutFormValues } from "@lib/context/checkout-context" import { emailRegex } from "@lib/util/regex" import ConnectForm from "@modules/common/components/connect-form" @@ -2068,96 +2370,199 @@ When a customer purchases a digital product, the shipping form shown during chec ### Download Product After Purchase -After the customer purchases the digital product, show a download button to allow them to immediately download the product. +After the customer purchases the digital product you can show a download button to allow them to immediately download the product.
- Before you implement the storefront changes, you need to create a new API Route that ensures that the currently logged-in customer has purchased the digital product and, if so, returns a presigned URL to download it. + Before you implement the storefront changes, you need to create a new API Route in the Medusa application that validates that the customer has already purchased the digital product before returning the presigned URL to download it. + + You’ll implement this logic in a workflow. Create the file `src/workflows/digital-product/get-url.ts` with the following content: - Create the file `src/api/store/product-media/download/[variant_id]/route.ts` with the following content: + ```ts title="src/api/store/product-media/download/route.ts" badgeLabel="Medusa Application" collapsibleLines="1-14" expandButtonLabel="Show Imports" + import { + createWorkflow, + transform, + createStep, + StepResponse + } from "@medusajs/workflows-sdk" + import { + IOrderModuleService, + IFileModuleService + } from "@medusajs/types" + import { ModuleRegistrationName } from "@medusajs/modules-sdk" + import DigitalProductModuleService from "../../modules/digital-product/service" + import { MediaType } from "../../types/digital-product/product-media" + + type GetPurchasedProductMediaUrlWorkflowInput = { + variant_id: string + customer_id: string + } + + type CheckVariantStepInput = + GetPurchasedProductMediaUrlWorkflowInput + + const checkVariantPurchasedStep = createStep( + "check-variant-purchased-step", + async ({ + variant_id, + customer_id + }: CheckVariantStepInput, { + container + }) => { + const orderModuleService: IOrderModuleService = + container.resolve(ModuleRegistrationName.ORDER) + const orders = await orderModuleService.list({ + customer_id, + }, { + relations: ["items"], + }) + + const found = orders.some((order) => ( + order.items.some((item) => { + if (item.variant_id === variant_id) { + return true + } + + return false + }) + )) + + if (!found) { + throw new Error("Customer hasn't purchased this product.") + } + } + ) + + type GetProductMediaStepInput = { + variant_id: string + } + + const getProductMediaStep = createStep( + "get-product-media-step", + async ({ variant_id }: GetProductMediaStepInput, { container }) => { + // get the product media and the presigned URL + const digitalProductModuleService: + DigitalProductModuleService = container.resolve( + "digitalProductModuleService" + ) + const productMedias = await digitalProductModuleService + .list({ + type: MediaType.MAIN, + variant_id, + }) + + return new StepResponse({ product_media: productMedias[0] }) + } + ) + + type GetFileUrlStepInput = { + id: string + } + + const getFileUrlStep = createStep( + "get-file-url-step", + async ({ id }: GetFileUrlStepInput, { container }) => { + const fileModuleService: IFileModuleService = container + .resolve(ModuleRegistrationName.FILE) + + const file = await fileModuleService.retrieve( + id + ) + + return new StepResponse(file.url) + } + ) + + type GetPurchasedProductMediaUrlWorkflowOutput = { + url: string + name: string + mime_type: string + } + + export const getPurchasedProductMediaUrlWorkflow = + createWorkflow< + GetPurchasedProductMediaUrlWorkflowInput, + GetPurchasedProductMediaUrlWorkflowOutput + >( + "get-purchased-product-media-url-workflow", + function (input) { + checkVariantPurchasedStep(input) + + const { product_media } = getProductMediaStep({ + variant_id: input.variant_id + }) + + const url = getFileUrlStep({ + id: product_media.file_key + }) + + const result = transform( + { + url, + product_media + }, + (transformInput) => ({ + url: transformInput.url, + name: transformInput.product_media.name, + mime_type: transformInput.product_media.mime_type + }) + ) + + return result + } + ) + ``` - ```ts title="src/api/store/product-media/download/route.ts" badgeLabel="Medusa Application" + This workflow has three steps: + + 1. Check that the customer has purchased this product variant before. + 2. Retrieve the product media associated with the variant. + 3. Retrieve the pre-signed URL of the product media file. + 4. Return the details of the file. + + Then, create the file `src/api/store/digital-products/download/[variant_id]/route.ts` in the Medusa backend with the following content: + + ```ts title="src/api/store/digital-products/download/[variant_id]/route.ts" collapsibleLines="1-8" expandButtonLabel="Show Imports" import type { - AbstractFileService, MedusaRequest, MedusaResponse, - OrderService, - ProductVariant, } from "@medusajs/medusa" - import ProductMediaService - from "../../../../services/product-media" - import { MediaType } from "../../../../models/product-media" - + import { + getPurchasedProductMediaUrlWorkflow + } from "../../../../../workflows/digital-product/get-url" + export const GET = async ( req: MedusaRequest, res: MedusaResponse ) => { - const variantId = req.params.variant_id - if (!variantId) { - throw new Error("Variant ID is required") - } - const ordersService = req.scope.resolve< - OrderService - >("orderService") - const orders = await ordersService.list({ - customer_id: req.user.customer_id, - }, { - relations: ["items", "items.variant"], - }) - - let variant: ProductVariant - orders.some((order) => ( - order.items.some((item) => { - if (item.variant_id === variantId) { - variant = item.variant - return true - } - - return false - }) - )) - - if (!variant) { - throw new Error("Customer hasn't purchased this product.") - } - - // get the product media and the presigned URL - const productMediaService = req.scope.resolve< - ProductMediaService - >("productMediaService") - const productMedias = await productMediaService.list({ - type: MediaType.MAIN, - variant_id: variant.id, - }) - - const fileService = req.scope.resolve< - AbstractFileService - >("fileService") - - res.json({ - url: await fileService.getPresignedDownloadUrl({ - fileKey: productMedias[0].file_key, - isPrivate: true, - }), - name: productMedias[0].name, - mime_type: productMedias[0].mime_type, + const { result } = await getPurchasedProductMediaUrlWorkflow( + req.scope + ).run({ + input: { + variant_id: req.params.variant_id, + customer_id: req.user.customer_id + } }) + + res.json(result) } ``` - - Then, add the `requireCustomerAuthentication` middleware to this API Route in `src/api/middlewares.ts`: - - ```ts title="src/api/middlewares.ts" badgeLabel="Medusa Application" - import { - requireCustomerAuthentication, + + Then, add the `authenticate` middleware to this API Route in `src/api/middlewares.ts`: + + ```ts title="src/api/middlewares.ts" + import { type MiddlewaresConfig, + authenticate } from "@medusajs/medusa" - + export const config: MiddlewaresConfig = { routes: [ { matcher: "/store/product-media/download/*", - middlewares: [requireCustomerAuthentication()], + middlewares: [ + authenticate("store", ["session", "bearer"]) + ], }, ], } @@ -2165,6 +2570,8 @@ After the customer purchases the digital product, show a download button to allo This ensures that only logged-in customers can access this API Route. + {/* TODO check steps */} + You can use this API Route in your storefront to add a button that allows downloading the purchased digital product. To mask the presigned URL, create a Next.js API route at `src/app/api/download/main/[variant_id]/route.ts` with the following content: @@ -2236,7 +2643,7 @@ After the customer purchases the digital product, show a download button to allo For example, you can change the `src/modules/order/components/item/index.tsx` file that handles showing each of the order's items in the order confirmation page to include a new download button if the customer is logged-in: - ```tsx title="src/modules/order/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" + ```tsx title="src/modules/order/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" collapsibleLines="1-8" expandButtonLabel="Show Imports" import { LineItem, Region } from "@medusajs/medusa" import { Button, Table, Text, clx } from "@medusajs/ui" import LineItemOptions from "@modules/common/components/line-item-options" @@ -2293,9 +2700,3 @@ After the customer purchases the digital product, show a download button to allo ```
- ---- - -## Additional Development - -Refer to other guides in the Medusa Resources or the Medusa Book for additional guidance during your development. diff --git a/www/apps/resources/app/recipes/ecommerce/page.mdx b/www/apps/resources/app/recipes/ecommerce/page.mdx new file mode 100644 index 0000000000..b2d622ba6c --- /dev/null +++ b/www/apps/resources/app/recipes/ecommerce/page.mdx @@ -0,0 +1,79 @@ +import { AcademicCapSolid, UsersSolid, PuzzleSolid } from "@medusajs/icons" +import { LearningPath } from "docs-ui" + +export const metadata = { + title: `Ecommerce Recipe`, +} + +# {metadata.title} + +This recipe provides the general steps to create an ecommerce store with Medusa. + +## Overview + +Businesses use ecommerce stores to: + +- Provide an online catalog for customers. +- Accept customer orders and payment. +- Manage their store's data and logistics. + +Medusa provides all essential commerce features out-of-the-box. Businesses can go live and start selling without making any adjustments. They can also power-up their store by integrating third-party services for payments, fulfillment, and more. + + + +[How Tekla created an ecommerce store using Medusa](https://medusajs.com/blog/tekla/). + + + +--- + +## Install Ecommerce Store Powered by Medusa + +Use the following command to install an ecommerce store with Medusa: + +```bash +npx create-medusa-app@preview --with-nextjs-starter +``` + +This installs: + +- The Medusa application, which is composed of a Node.js server and a Medusa Admin dashboard. The dashboard opens right after the installation finishes, where you can create a user and start managing your store's data. +- A [Next.js starter storefront](../../nextjs-starter/page.mdx) that connects to your Medusa application to provide customers with ecommerce features. + +--- + +## Integrate Third-Party Services + +You can integrate third-party services and tools, customizing the architecture and commerce features of your store. + +For example, you can integrate Stripe for payment, or SendGrid for notifications. + +} + showLinkIcon={false} +/> + +--- + +## Deploy the Medusa Application + +Our documentation has step-by-step guides to deploy your Medusa application, Medusa Admin, and the Next.js starter storefront. + +} + showLinkIcon={false} +/> + +--- + +## Add Custom Features + +Along with the extensive ecommerce features, Medusa also provides the architecture and tools to customize and add new features. + +To learn how to develop customziations with Medusa, refer to the [Medusa documentation](!docs!) diff --git a/www/apps/resources/app/recipes/integrate-ecommerce-stack/page.mdx b/www/apps/resources/app/recipes/integrate-ecommerce-stack/page.mdx new file mode 100644 index 0000000000..4db2c3ae74 --- /dev/null +++ b/www/apps/resources/app/recipes/integrate-ecommerce-stack/page.mdx @@ -0,0 +1,365 @@ +import { AcademicCapSolid } from "@medusajs/icons" +import { LearningPath } from "docs-ui" + +export const metadata = { + title: `Integrate Ecommerce Stack Recipe`, +} + +# {metadata.title} + +This recipe provides the general steps to integrate your ecommerce stack with Medusa. + +## Overview + +Integrating third-party systems, such as ERP or a CMS, into your ecommerce stack requires: + +- Establishing connections with the different systems based on each of their APIs. +- Building flows that span across multiple systems. +- Maintaining data consistency and syncing between your systems. + +Medusa’s architecture and functionalities allow you to integrate third-party systems and build flows around them. It also provides error-handling mechanisms and webhook capabilities that prevent data inconsistency between your systems. + +--- + +## Integrate External System with a Module + +To integrate an external system, such as an ERP, into your Medusa application, create a module whose service has methods to connect to the external system. + +Then, resolve the module's main service in other resources, such as API routes or workflows, to perform actions in the external system. + +} + showLinkIcon={false} +/> + +
+ + This example showcases how to create a module that integrates to a dummy ERP system. + + Start by creating the directory `src/modules/erp` for your module. + + Then, create the file `src/modules/erp/service.ts` with the following content: + +export const serviceHighlights = [ + ["4", "ErpModuleOptions", "The module's expected options."], + ["9", "client_", "The client to connect to the external system."], + ["12", "create", "Create the client using Axios. Can instead use an SDK if the external system has one."], + ["20", "getProductData", "This method retrieves the product's data from the ERP system."], + ["26", "createProduct", "This method creates a product in the ERP system."], + ["32", "deleteProduct", "This method deletes the product in the ERP system."] +] + + ```ts title="src/modules/erp/service.ts" highlights={serviceHighlights} + import axios, { AxiosInstance } from "axios" + import { ProductDTO } from "@medusajs/types" + + type ErpModuleOptions = { + apiKey: string + } + + class ErpModuleService { + private client_: AxiosInstance + + constructor({}, { apiKey }: ErpModuleOptions) { + this.client_ = axios.create({ + baseURL: `https://api.erp-example.com`, + headers: { + Authorization: `Bearer ${apiKey}` + } + }) + } + + async getProductData (id: string) { + const { data: erpProduct } = await this.client_.get(`/product/${id}`) + + return erpProduct + } + + async createProduct(data: ProductDTO) { + const { data: erpProduct } = await this.client_.post(`/product`, data) + + return erpProduct + } + + async deleteProduct(id: string) { + await this.client_.delete(`/product/${id}`) + } + } + + export default ErpModuleService + ``` + + This creates the module's main service. Few things to note: + + - The module accepts an `apiKey` option, used to authenticate to the dummy ERP system. The module's main service accesses this option in the second parameter of the constructor. + - The module uses axios to create a client in the constructor. The client is used in the service's methods when connecting to the ERP system. If the system you're integrating has an SDK, you can initialize it in the constructor, instead. + - The `getProductData` method retrieves a product's details from the ERP system by sending a `GET` request using the client. + - The `createProduct` method creates a product in the ERP system by sending a `POST` request using the client. + - The `deleteProduct` method deletes a product in the ERP system by sending a `DELETE` request using the client. + + + + You can store the product's ID in the external system using the `metadata` field of the `Product` data model in the Product Module. Alternatively, you can create a [data model](!docs!/basics/data-models) in your module to store data related to the external system. + + + + Then, create the module's definition file at `src/modules/erp/index.ts` with the following content: + + ```ts title="src/modules/erp/index.ts" + import ErpModuleService from "./service"; + + export default { + service: ErpModuleService + } + ``` + + Finally, add the module to the `modules` object in `medusa-config.js`: + + ```js title="medusa-config.js" highlights={[["7", "ERP_API_KEY", "The environment variable holding the API key of the ERP system."]]} + module.exports = defineConfig({ + // ... + modules: { + erpModuleService: { + resolve: "./modules/erp", + options: { + apiKey: process.env.ERP_API_KEY + } + }, + } + }) + ``` + +
+ + +--- + +## Build Flows Across Systems + +With Medusa’s workflows, build flows with steps that perform actions across systems. + +For example, you can create a workflow that creates a product in integrated systems like ERPs, WMSs, and CMSs. + +Workflows can be executed from anywhere. So, taking the workflow described above, you can listen to the `product.created` event using a subscriber and execute the workflow whenever the event is triggered. + +![A flowchart of how the workflow is executed when the product.updated event is triggered](https://res.cloudinary.com/dza7lstvk/image/upload/v1709025778/Medusa%20Book/workflow-recipe-example_yfwfuy.jpg) + +Workflows guarantee data consistency through their compensation feature. You can provide a compensation function to steps that roll back the actions of that step. Then, if an error occurs in any step, the actions of previous steps are rolled back using their compensation function. + +} + showLinkIcon={false} +/> + +
+ + For example, create the following workflow in `src/workflows/create-product.ts`: + +export const workflowHighlights = [ + ["18", "createInErpStep", "A step that creates a product in the ERP system."], + ["21", "erpModuleService", "Resolve the ERP Module's main service."], + ["24", "productModuleService", "Resolve the Product Module's main service."], + ["28", "retrieve", "Retrieve the created product's data."], + ["30", "createProduct", "Create the product in the ERP system."], + ["34", "update", "Update the product in Medusa with the ID of the ERP product."], + ["43", "erpId", "Pass the ERP product's ID to the compensation function."], + ["44", "productId", "Pass the product's ID to the compensation function."], + ["46", "", "Define a compensation function that rolls back changes when an error occurs."], + ["53", "deleteProduct", "Undo creating the product in the ERP system by deleting it."], + ["54", "update", "Update the product in Medusa to remove the ERP product's ID."] +] + + ```ts title="src/workflows/create-product.ts" highlights={workflowHighlights} collapsibleLines="1-9" expandButtonLabel="Show Imports" + import { + createStep, + StepResponse, + createWorkflow + } from "@medusajs/workflows-sdk" + import { IProductModuleService } from "@medusajs/types" + import { ModuleRegistrationName } from "@medusajs/modules-sdk" + import ErpModuleService from "../modules/erp/service" + + type WorkflowInput = { + productId: string + } + + type WorkflowOutput = { + erpProduct: any + } + + const createInErpStep = createStep( + "create-in-erp", + async ({ productId }: WorkflowInput, { container }) => { + const erpModuleService: ErpModuleService = container.resolve( + "erpModuleService" + ) + const productModuleService: IProductModuleService = container + .resolve(ModuleRegistrationName.PRODUCT) + + const createdProductData = await productModuleService + .retrieve(productId) + + const erpProduct = await erpModuleService.createProduct( + createdProductData + ) + + await productModuleService.update(productId, { + metadata: { + erp_id: erpProduct.id + } + }) + + return new StepResponse({ + erpProduct + }, { + erpId: erpProduct.id, + productId + }) + }, async ({ erpId, productId }, { container}) => { + const erpModuleService: ErpModuleService = container.resolve( + "erpModuleService" + ) + const productModuleService: IProductModuleService = container + .resolve(ModuleRegistrationName.PRODUCT) + + await erpModuleService.deleteProduct(erpId) + await productModuleService.update(productId, { + metadata: {} + }) + }) + + const createProductWorkflow = createWorkflow< + WorkflowInput, WorkflowOutput + >("create-product-in-systems", function (input) { + const erpData = createInErpStep(input) + + return erpData + }) + + export default createProductWorkflow + ``` + + This workflow has one step that: + + - Retrieves the product's data using the Product Module's main service. + - Create the product in the ERP system using the ERP Module's main service. + - Updates the product in Medusa by setting the ID of the ERP product in the product's `metadata` field. + + The step also has a compensation function that rolls back changes when an error occurs. It deletes the product in the ERP system and removes the ID of the ERP product in the Medusa product. + + Then, create the subscriber at `src/subscribers/create-product.ts`: + + ```ts title="src/subscribers/create-product.ts" + import type { + SubscriberConfig, + SubscriberArgs, + } from "@medusajs/medusa" + import createProductWorkflow from "../workflows/create-product" + + export default async function handleProductUpdate({ + data, container + }: SubscriberArgs<{id: string}>) { + createProductWorkflow(container) + .run({ + input: { + productId: "data" in data ? data.data.id : data.id + } + }) + .then(() => { + console.log("Created product across systems.") + }) + } + + export const config: SubscriberConfig = { + event: "product.created", + } + ``` + + The subscriber executes the workflow whenever the `product.created` event is triggered, passing it the ID of the created product. + +
+ +--- + +## Create Webhook Listeners + +You can provide webhook listeners that your external systems call when their data is updated. This lets you synchronize data between your systems. To create webhook listeners, create an API route. + +For example, suppose an administrator changes the product data in the ERP system. The system then sends a request to the webhook you define in your Medusa application, which updates the product data in the application. + +} + showLinkIcon={false} +/> + +
+ + For example, create the file `src/api/webhooks/erp/update/route.ts` with the following content: + + ```ts title="src/api/webhooks/erp/update/route.ts" collapsibleLines="1-12" expandButtonLabel="Show Imports" + import { + MedusaRequest, + MedusaResponse, + } from "@medusajs/medusa" + import { + IProductModuleService, + UpdateProductDTO + } from "@medusajs/types" + import { + ModuleRegistrationName + } from "@medusajs/modules-sdk" + + type WebhookReq = { + id: string + updatedData: UpdateProductDTO + } + + export async function POST( + req: MedusaRequest, + res: MedusaResponse + ) { + const { id, updatedData} = req.body + + const productService: IProductModuleService = req.scope + .resolve( + ModuleRegistrationName.PRODUCT + ) + + await productService.update(id, updatedData) + + res.status(200) + } + ``` + + This creates a webhook listener for an ERP system. It receives the ID of a product and its updated data, assuming that’s how your ERP system sends the data. + + Then, create the file `src/api/middlewares.ts` with the following content: + + ```ts title="src/api/middlewares.ts" + import { MiddlewaresConfig } from "@medusajs/medusa" + import { raw } from "body-parser" + + export const config: MiddlewaresConfig = { + routes: [ + { + method: ["POST", "PUT"], + matcher: "/webhooks/*", + bodyParser: false, + middlewares: [raw({ type: "application/json" })], + }, + ], + } + ``` + + This replaces the default JSON middleware with the raw middleware, which is useful for webhook routes. + +
diff --git a/www/apps/resources/app/recipes/marketplace/page.mdx b/www/apps/resources/app/recipes/marketplace/page.mdx new file mode 100644 index 0000000000..4101168d7d --- /dev/null +++ b/www/apps/resources/app/recipes/marketplace/page.mdx @@ -0,0 +1,1018 @@ +import { AcademicCapSolid } from "@medusajs/icons" + +export const metadata = { + title: `Marketplace Recipe`, +} + +# {metadata.title} + +This recipe provides the general steps to implement a marketplace in your Medusa application. + + + +This recipe is a work in progress, as some features are not ready yet in Medusa V2. + + + +## Overview + +A marketplace is an online commerce store that allows different vendors to sell their products within the same commerce system. Customers can purchase products from any of these vendors, and vendors can manage their orders separately. + +In Medusa, you can create a Marketplace Module that establishes the relations between a store, users, products, and orders. Using these relations, you can implement different stores for different users, with products and orders associated with that store. + + + +[How Goodchef built subscription-based purchases with Medusa](https://medusajs.com/blog/foraged/). + + + +--- + +## Create Relationships Between Data Models + +In a marketplace, an admin user has a store where they manage their products and orders, among other details. + +Create a Marketplace Module that holds and manages these relationships. + +, + showLinkIcon: false + }, + { + href: "/v2/advanced-development/modules/module-relationships", + title: "Module Relationships", + text: "Create relationships between modules.", + startIcon: , + showLinkIcon: false + }, +]} /> + +
+ + In this section, you’ll create a Marketplace Module with the necessary relationships and functionalities in the main service. + + Start by creating the directory `src/modules/marketplace`. + + Then, create the file `src/modules/marketplace/models/store-user.ts` with the following content: + + ```ts title="src/modules/marketplace/models/store-user.ts" + import { BaseEntity } from "@medusajs/utils"; + import { Entity, PrimaryKey, Property } from "@mikro-orm/core" + + @Entity() + class StoreUser extends BaseEntity { + @PrimaryKey() + id!: string + + @Property({ columnType: "text" }) + store_id: string + + @Property({ columnType: "text" }) + user_id: string + } + + export default StoreUser + ``` + + This creates a `StoreUser` data model with the `store_id` and `user_id` fields. These fields will be used later to establish relationships to the Store and User modules. + + Next, create the file `src/modules/marketplace/models/store-product.ts` with the following content: + + ```ts title="src/modules/marketplace/models/store-product.ts" + import { BaseEntity } from "@medusajs/utils"; + import { Entity, PrimaryKey, Property } from "@mikro-orm/core" + + @Entity() + class StoreProduct extends BaseEntity { + @PrimaryKey() + id!: string + + @Property({ columnType: "text" }) + store_id: string + + @Property({ columnType: "text" }) + product_id: string + } + + export default StoreProduct + ``` + + This creates a `StoreProduct` data model with the `store_id` and `product_id` fields. These fields will be used later to establish relationships to the Store and Product modules. + + Finally, create the file `src/modules/marketplace/models/store-order.ts` with the following content: + + ```ts title="src/modules/marketplace/models/store-order.ts" + import { BaseEntity } from "@medusajs/utils"; + import { Entity, PrimaryKey, Property } from "@mikro-orm/core" + + @Entity() + class StoreOrder extends BaseEntity { + @PrimaryKey() + id!: string + + @Property({ columnType: "text" }) + store_id: string + + @Property({ columnType: "text" }) + order_id: string + + @Property({ columnType: "text" }) + parent_order_id?: string + } + + export default StoreOrder + ``` + + This creates a `StoreOrder` data model with the `store_id`, `order_id`, and `parent_order_id` fields. The `store_id` and `order_id` fields will be used to establish relationships to the Store and Order modules. You’ll learn about the use of `parent_order_id` in a later section. + + + + You can add relationships to data models of other modules in a similar manner. + + + + To reflect these changes on the database, create the migration `src/modules/marketplace/migrations/Migration20240514143248.ts` with the following content: + + ```ts title="src/modules/marketplace/migrations/Migration20240514143248.ts" + import { Migration } from '@mikro-orm/migrations'; + + export class Migration20240514143248 extends Migration { + + async up(): Promise { + this.addSql('create table if not exists "store_order" ("id" varchar(255) not null, "store_id" text not null, "order_id" text not null, "parent_order_id" text not null, constraint "store_order_pkey" primary key ("id"));'); + + this.addSql('create table if not exists "store_product" ("id" varchar(255) not null, "store_id" text not null, "product_id" text not null, constraint "store_product_pkey" primary key ("id"));'); + + this.addSql('create table if not exists "store_user" ("id" varchar(255) not null, "store_id" text not null, "user_id" text not null, constraint "store_user_pkey" primary key ("id"));'); + } + + async down(): Promise { + this.addSql('drop table if exists "store_order" cascade;'); + + this.addSql('drop table if exists "store_product" cascade;'); + + this.addSql('drop table if exists "store_user" cascade;'); + } + + } + + ``` + + You’ll run the migration after registering the module in the Medusa configurations. + + Next, you’ll create types that you’ll use throughout your customizations. + + Create the file `src/types/marketplace/index.ts` with the following content: + + ```ts title="src/types/marketplace/index.ts" + import { + StoreDTO, + UserDTO, + ProductDTO, + OrderDTO + } from "@medusajs/types" + + export type StoreUserDTO = { + id: string + store_id: string + user_id: string + store?: StoreDTO + user?: UserDTO + } + + export type StoreProductDTO = { + id: string + store_id: string + product_id: string + store?: StoreDTO + product?: ProductDTO + } + + export type StoreOrderDTO = { + id: string + store_id: string + order_id: string + parent_order_id?: string + store?: StoreDTO + order?: OrderDTO + } + + export type CreateStoreUserDTO = { + store_id: string + user_id: string + } + + export type CreateStoreProductDTO = { + store_id: string + product_id: string + } + + export type CreateStoreOrderDTO = { + store_id: string + order_id: string + parent_order_id?: string + } + + ``` + + Then, create the module’s main service at `src/modules/marketplace/service.ts` with the following content: + +export const mainServiceHighlights = [ + ["48", "abstractModuleServiceFactory", "Extends the service factory to generate basic data management features."], + ["102", "relationships", "Defines relationships to the Store, User, Product, and Order modules."], + ["131", "create", "Method to create a `StoreUser`."], + ["141", "createStoreProduct", "Method to create a `StoreProduct`."], + ["150", "createStoreOrder", "Method to create a `StoreOrder`."] +] + + ```ts title="src/modules/marketplace/service.ts" highlights={mainServiceHighlights} collapsibleLines="1-17" expandButtonLabel="Show Imports" + import { ModulesSdkUtils, Modules } from "@medusajs/utils" + import StoreUser from "./models/store-user"; + import StoreProduct from "./models/store-product"; + import StoreOrder from "./models/store-order"; + import { + CreateStoreOrderDTO, + CreateStoreProductDTO, + CreateStoreUserDTO, + StoreOrderDTO, + StoreProductDTO, + StoreUserDTO + } from "../../types/marketplace"; + import { + ModuleJoinerConfig, + ModulesSdkTypes + } from "@medusajs/types"; + + type InjectedDependencies = { + storeUserService: ModulesSdkTypes.InternalModuleService< + any + > + storeProductService: ModulesSdkTypes.InternalModuleService< + any + > + storeOrderService: ModulesSdkTypes.InternalModuleService< + any + > + } + + type AllModelsDTO = { + StoreUser: { + dto: StoreUserDTO + }, + StoreProduct: { + dto: StoreProductDTO + }, + StoreOrder: { + dto: StoreOrderDTO + } + } + + const generateMethodsFor = [ + StoreProduct, + StoreOrder + ] + + class MarketplaceModuleService extends ModulesSdkUtils + .abstractModuleServiceFactory< + InjectedDependencies, + StoreUserDTO, + AllModelsDTO + >( + StoreUser, generateMethodsFor + ) { + storeUserService_: ModulesSdkTypes.InternalModuleService< + StoreUser + > + storeProductService_: ModulesSdkTypes.InternalModuleService< + StoreProduct + > + storeOrderService_: ModulesSdkTypes.InternalModuleService< + StoreOrder + > + + constructor({ + storeUserService, + storeProductService, + storeOrderService + }: InjectedDependencies) { + // @ts-ignore + super(...arguments) + this.storeUserService_ = storeUserService + this.storeProductService_ = storeProductService + this.storeOrderService_ = storeOrderService + } + + __joinerConfig(): ModuleJoinerConfig { + return { + serviceName: "marketplaceModuleService", + alias: [ + { + name: ["store_user"], + args: { + entity: StoreUser.name + } + }, + { + name: ["store_product"], + args: { + entity: StoreProduct.name, + methodSuffix: "StoreProducts" + } + }, + { + name: ["store_order"], + args: { + entity: StoreOrder.name, + methodSuffix: "StoreOrders" + } + } + ], + relationships: [ + { + serviceName: Modules.STORE, + alias: "store", + primaryKey: "id", + foreignKey: "store_id" + }, + { + serviceName: Modules.USER, + alias: "user", + primaryKey: "id", + foreignKey: "user_id" + }, + { + serviceName: Modules.PRODUCT, + alias: "product", + primaryKey: "id", + foreignKey: "product_id" + }, + { + serviceName: Modules.ORDER, + alias: "order", + primaryKey: "id", + foreignKey: "order_id" + } + ] + } + } + + async create( + data: CreateStoreUserDTO + ): Promise { + const storeUser = await this.storeUserService_.create( + data + ) + + return storeUser + } + + async createStoreProduct( + data: CreateStoreProductDTO + ): Promise { + const storeProduct = await this.storeProductService_ + .create(data) + + return storeProduct + } + + async createStoreOrder( + data: CreateStoreOrderDTO + ): Promise { + const storeOrder = await this.storeOrderService_ + .create(data) + + return storeOrder + } + } + + export default MarketplaceModuleService + ``` + + The module’s main service: + + - Extends the service factory to generate basic data management features. + - Defines relationships to the Store, User, Product, and Order modules. + - Defines `create` methods for the `StoreUser`, `StoreProduct`, and `StoreOrder` data models. + + Finally, create the module definition at `src/modules/marketplace/index.ts` with the following content: + + ```ts title="src/modules/marketplace/index.ts" + import MarketplaceModuleService from "./service"; + + export default { + service: MarketplaceModuleService, + } + ``` + + To use the module, add it to the `modules` object in `medusa-config.js`: + + ```js title="medusa-config.js" + module.exports = defineConfig({ + // ... + modules: { + marketplaceModuleService: { + resolve: "./modules/marketplace", + definition: { + isQueryable: true + } + }, + } + }) + ``` + + Then, run the migrations of the module: + + ```bash npm2yarn + npx medusa migrations run + ``` + +
+ + +--- + +## Attach Users to Stores + +To attach admin users to their own stores, create a subscriber that listens to the `user.created` event and attaches the user to the store. + +} + showLinkIcon={false} +/> + +
+ + Create the file `src/subscribers/user-created.ts` with the following content: + +export const userSubscriberHighlights = [ + ["10", "", "The event data payload with the created user's ID."], + ["24", "", "Retrieve the created user."], + ["26", "", "Create a store for that user using the Store Module."], + ["30", "", "Create a relationship between the user and the store by creating a `StoreUser` record."] +] + + ```ts title="src/subscribers/user-created.ts" highlights={userSubscriberHighlights} collapsibleLines="1-8" expandButtonLabel="Show Imports" + import { SubscriberArgs, type SubscriberConfig } from "@medusajs/medusa" + import { ModuleRegistrationName } from "@medusajs/modules-sdk" + import { + IUserModuleService, + IStoreModuleService + } from "@medusajs/types" + import MarketplaceModuleService from "../modules/marketplace/service" + + export default async function userCreatedHandler({ + data, + container + }: SubscriberArgs<{ id: string }>) { + const { id } = data.data || { data } + const userModuleService: IUserModuleService = container.resolve( + ModuleRegistrationName.USER + ) + const storeModuleService: IStoreModuleService = container.resolve( + ModuleRegistrationName.STORE + ) + const marketplaceModuleService: MarketplaceModuleService = container.resolve( + "marketplaceModuleService" + ) + + const user = await userModuleService.retrieve(id) + + const store = await storeModuleService.create({ + name: `${user.first_name}'s Store` + }) + + const storeUser = await marketplaceModuleService.create({ + store_id: store.id, + user_id: user.id + }) + + console.log(`Created StoreUser ${storeUser.id}`) + } + + export const config: SubscriberConfig = { + event: "user.created", + } + ``` + + This adds a subscriber to the `user.created` event. In the subscriber, you: + + - Retrieve the created user. The created user’s ID is passed in the event’s data payload. + - Create a store for that user using the Store Module. + - Create a relationship between the user and the store by creating a `StoreUser` record. + + To test it out, use the `medusa user` command to create a user: + + ```bash npm2yarn + npx medusa user -e my-admin@medusa-test.com -p supersecret + ``` + + At the end of the output, you should see the message `Created StoreUser {store_user_id}` where the `{store_user_id}` is the ID of the created `StoreUser`. + +
+ +--- + +## Attach Products to Stores + +Similar to the previous section, to attach products to stores, create a subscriber that listens to the `product.created` event. In the subscriber, you attach the product to the store it’s created in. + +} + showLinkIcon={false} +/> + +
+ + Create the file `src/subscribers/product-created.ts` with the following content: + +export const productSubscriberHighlights = [ + ["21", "", "Retrieve the created product."], + ["23", "", "This subscriber requires the store ID to be set in `product.metadata.store_id`. If not, the subscriber ends execution."], + ["27", "", "Create a relationship between the product and the store by creating a `StoreProduct` record."] +] + + ```ts title="src/subscribers/product-created.ts" highlights={productSubscriberHighlights} collapsibleLines="1-7" expandButtonLabel="Show Imports" + import { SubscriberArgs, type SubscriberConfig } from "@medusajs/medusa" + import { ModuleRegistrationName } from "@medusajs/modules-sdk" + import { + IProductModuleService, + } from "@medusajs/types" + import MarketplaceModuleService from "../modules/marketplace/service" + + export default async function productCreateHandler({ + data, + container + }: SubscriberArgs<{ id: string }>) { + const { id } = data.data || data + const productModuleService: IProductModuleService = container.resolve( + ModuleRegistrationName.PRODUCT + ) + + const marketplaceModuleService: MarketplaceModuleService = container.resolve( + "marketplaceModuleService" + ) + + const product = await productModuleService.retrieve(id) + + if (!product.metadata?.store_id) { + return + } + + await marketplaceModuleService.createStoreProduct({ + store_id: product.metadata.store_id as string, + product_id: id + }) + } + + export const config: SubscriberConfig = { + event: "product.created", + } + ``` + + This adds a subscriber to the `product.created` event. In the subscriber, you: + + - Retrieve the created product. + - This subscriber requires the store ID to be set in `product.metadata.store_id`. If not, the subscriber ends execution. + - If the store ID is found, create a relationship between the product and the store by creating a `StoreProduct` record. + + To test it out, start the Medusa application: + + ```bash npm2yarn + npm run dev + ``` + + Then, send an authenticated `POST` request to `/admin/products`: + + ```bash + curl -X POST 'http://localhost:9000/admin/products' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer {jwt_token}' \ + --data '{ + "title": "My Shirt 13", + "metadata": { + "store_id": "store_01HXV74JCCAHA3F2X3EWZTZG5R" + } + }' + ``` + + This returns the created product. In the next section, you’ll implement the API route to fetch the products of a store. + +
+ + +--- + +## Retrieve Store’s Products + +To allow admin users to view their store’s products, create an API route that uses the remote query to fetch the products based on the logged-in user’s store. + +, + showLinkIcon: false + }, + { + href: "/v2/advanced-development/modules/remote-query", + title: "Remote Query", + text: "Use the remote query to fetch data across modules.", + startIcon: , + showLinkIcon: false + }, +]} /> + +
+ + Create the file `src/api/admin/marketplace/products/route.ts` with the following content: + +export const productRoutesHighlights = [ + ["26", "", "Retrieve the store of the logged-in user."], + ["37", "", "Build a query that retrieves the products of that store."], + ["49", "", "Retrieve the products using remote query."] +] + + ```ts title="src/api/admin/marketplace/products/route.ts" highlights={productRoutesHighlights} collapsibleLines="1-13" expandButtonLabel="Show Imports" + import { + AuthenticatedMedusaRequest, + MedusaResponse + } from "@medusajs/medusa" + import { RemoteQueryFunction } from "@medusajs/modules-sdk" + import { + remoteQueryObjectFromString, + ContainerRegistrationKeys, + MedusaError + } from "@medusajs/utils" + import MarketplaceModuleService + from "../../../../modules/marketplace/service" + + export async function GET( + req: AuthenticatedMedusaRequest, + res: MedusaResponse + ): Promise { + const marketplaceModuleService: MarketplaceModuleService = + req.scope.resolve( + "marketplaceModuleService" + ) + const remoteQuery: RemoteQueryFunction = req.scope.resolve( + ContainerRegistrationKeys.REMOTE_QUERY + ) + + const storeUsers = await marketplaceModuleService.list({ + user_id: req.auth.actor_id + }) + + if (!storeUsers.length) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + "This user doesn't have an associated store." + ) + } + + const query = remoteQueryObjectFromString({ + entryPoint: "store_product", + fields: [ + "product.*" + ], + variables: { + filters: { + store_id: storeUsers[0].store_id + } + } + }) + + const result = await remoteQuery(query) + + res.json({ + store_id: storeUsers[0].store_id, + products: result.map((data) => data.product) + }) + } + ``` + + This creates a `GET` API route at `/admin/marketplace/products`. In the API route, you: + + - Retrieve the store of the logged-in user. + - Build a query that retrieves the products of that store. + - Retrieve the products using remote query and return them. + + Next, create the file `src/api/middlewares.ts` with the following content: + + ```ts title="src/api/middlewares.ts" + import { + MiddlewaresConfig, + authenticate + } from "@medusajs/medusa" + + export const config: MiddlewaresConfig = { + routes: [ + { + matcher: "/admin/marketplace*", + middlewares: [ + authenticate( + "admin", + ["session", "bearer", "api-key"] + ), + ], + }, + ], + } + ``` + + This ensures that only authenticated admin users can access your API route. + + To test it out, start the Medusa application. Then, send a `GET` request to `/admin/marketplace/products`: + + ```bash + curl 'localhost:9000/admin/marketplace/products' \ + -H 'Authorization: Bearer {jwt_token}' + ``` + + This will return the product you created in the previous section, if the `{jwt_token}` belongs to the user of the same store ID. + +
+ + +--- + +## Split Orders Based on Stores + + + +While this section showcases the implementation, the `order.placed` event is still not emitted in Medusa V2. + + + +An order may contain items from different stores. To ensure that users can only view and manage their orders, create a subscriber that listens to the `order.placed` event and handles splitting the order into multiple orders based on the items’ stores. + +} + showLinkIcon={false} +/> + +
+ + Create the file `src/subscribers/order-created.ts` with the following content: + +export const orderSubscriberHighlights = [ + ["35", "", "Loop over the created order’s items."], + ["57", "", "Group the items by their store ID."], + ["72", "", "If the items have the same store ID, then associate the created order with the store."], + ["88", "", "If there are items from more than one store in the order, create child orders and associate each of them with the store."], + ["91", "parent_order_id", "The `parent_order_id` field in the `StoreOrder` data model points to the original order."] +] + + ```ts title="src/subscribers/order-created.ts" highlights={orderSubscriberHighlights} collapsibleLines="1-13" expandButtonLabel="Show Imports" + import type { + SubscriberArgs, + SubscriberConfig + } from "@medusajs/medusa" + import { ModuleRegistrationName } from "@medusajs/modules-sdk" + import { + IOrderModuleService, + CreateOrderDTO + } from "@medusajs/types" + import MarketplaceModuleService + from "../modules/marketplace/service" + import { createOrdersWorkflow } from "@medusajs/core-flows" + + export default async function orderCreatedHandler({ + data, + container + }: SubscriberArgs<{ id: string }>) { + const { id } = data.data || data + const orderModuleService: IOrderModuleService = + container.resolve( + ModuleRegistrationName.ORDER + ) + + const marketplaceModuleService: MarketplaceModuleService = + container.resolve( + "marketplaceModuleService" + ) + + const storeToOrders: Record = {} + + const order = await orderModuleService.retrieve(id, { + relations: ["items"] + }) + + await Promise.all(order.items?.map(async (item) => { + const storeProduct = await marketplaceModuleService + .listStoreProducts({ + product_id: item.product_id + }) + + if (!storeProduct.length) { + return + } + + const storeId = storeProduct[0].store_id + + if (!storeToOrders[storeId]) { + const { id, ...orderDetails } = order + storeToOrders[storeId] = { + ...orderDetails, + items: [] + } + } + + const { id, ...itemDetails } = item + + storeToOrders[storeId].items.push(itemDetails) + })) + + const storeToOrdersKeys = Object.keys(storeToOrders) + + if (!storeToOrdersKeys.length) { + return + } + + if ( + storeToOrdersKeys.length === 1 && + storeToOrders[0].items.length === order.items.length + ) { + // The order is composed of items from one store, so + // associate the order as-is with the store. + await marketplaceModuleService.createStoreOrder({ + store_id: storeToOrdersKeys[0], + order_id: order.id + }) + + return + } + + // create store orders for each child order + await Promise.all( + storeToOrdersKeys.map(async (storeId) => { + const { result } = await createOrdersWorkflow(container) + .run({ + input: storeToOrders[storeId] + }) + + await marketplaceModuleService.createStoreOrder({ + store_id: storeId, + order_id: result.id, + parent_order_id: order.id + }) + }) + ) + } + + export const config: SubscriberConfig = { + event: "order.placed", + } + ``` + + This adds a subscriber to the `order.placed` event. In the subscriber, you: + + - Loop over the created order’s items. + - Group the items by their store ID. + - If the items have the same store ID, then associate the created order with the store. + - If there are items from more than one store in the order, create child orders and associate each of them with the store. Here, you use the `parent_order_id` field in the `StoreOrder` data model to point to the original order. + + To test this out, create an order in your store. That will run the subscriber and create the child orders. + + The next section covers how to retrieve the store’s orders. + +
+ + +--- + +## Retrieve Store’s Orders + +Similar to products, to allow admin users to view their store’s orders, create an API route that uses the remote query to fetch the orders based on the logged-in user’s store. + +, + showLinkIcon: false + }, + { + href: "/v2/advanced-development/modules/remote-query", + title: "Remote Query", + text: "Use the remote query to fetch data across modules.", + startIcon: , + showLinkIcon: false + }, +]} /> + +
+ + Create the file `src/api/admin/marketplace/orders/route.ts` with the following content: + +export const orderRoutesHighlights = [ + ["25", "", "Retrieve the store of the logged-in user."], + ["29", "", "Build a query that retrieves the orders of that store."], + ["41", "", "Retrieve the orders using remote query."] +] + + ```ts title="src/api/admin/marketplace/orders/route.ts" highlights={orderRoutesHighlights} collapsibleLines="1-12" expandButtonLabel="Show Imports" + import { + AuthenticatedMedusaRequest, + MedusaResponse + } from "@medusajs/medusa" + import { RemoteQueryFunction } from "@medusajs/modules-sdk" + import { + remoteQueryObjectFromString, + ContainerRegistrationKeys, + } from "@medusajs/utils" + import MarketplaceModuleService + from "../../../../modules/marketplace/service" + + export async function GET( + req: AuthenticatedMedusaRequest, + res: MedusaResponse + ): Promise { + const marketplaceModuleService: MarketplaceModuleService = + req.scope.resolve( + "marketplaceModuleService" + ) + const remoteQuery: RemoteQueryFunction = req.scope.resolve( + ContainerRegistrationKeys.REMOTE_QUERY + ) + + const storeUsers = await marketplaceModuleService.list({ + user_id: req.auth.actor_id + }) + + const query = remoteQueryObjectFromString({ + entryPoint: "store_order", + fields: [ + "order.*" + ], + variables: { + filters: { + store_id: storeUsers[0].store_id + } + } + }) + + const result = await remoteQuery(query) + + res.json({ + orders: result.map((data) => data.order) + }) + } + + ``` + + This creates a `GET` API route at `/admin/marketplace/orders`. In the API route, you: + + - Retrieve the store of the logged-in user. + - Build a query that retrieves the orders of that store. + - Retrieve the orders using remote query and return them. + + To test it out, start the Medusa application. Then, send a `GET` request to `/admin/marketplace/orders`: + + ```bash + curl 'localhost:9000/admin/marketplace/orders' \ + -H 'Authorization: Bearer {jwt_token}' + ``` + + This will return the orders you created in the previous section if the `{jwt_token}` belongs to the user of the same store ID. + +
+ +--- + +## Customize Storefront + +Medusa provides a Next.js storefront to use with your application. You can either customize it or build your own to represent your marketplace features. + +For example, you can create an API route that retrieves available stores and another API route that retrieves the products of each store using the remote query as done in previous sections. + +, + showLinkIcon: false + }, + { + href: "!docs!/storefront-development/tips", + title: "Storefront Tips", + text: "Find tips on developing a custom storefront.", + startIcon: , + showLinkIcon: false + }, +]} /> diff --git a/www/apps/resources/app/recipes/multi-region-store/page.mdx b/www/apps/resources/app/recipes/multi-region-store/page.mdx new file mode 100644 index 0000000000..37ca3edf9c --- /dev/null +++ b/www/apps/resources/app/recipes/multi-region-store/page.mdx @@ -0,0 +1,178 @@ +import { AcademicCapSolid, UsersSolid } from "@medusajs/icons" +import { LearningPath } from "docs-ui" + +export const metadata = { + title: `Multi-Region Store Recipe`, +} + +# {metadata.title} + +This recipe provides the general steps to build a multi-region store with Medusa. + +## Overview + +A multi-regional store allows merchants to sell across different countries. This includes supporting each country's tax rules, currency, available shipping and payment options, and more. + +Medusa comes with multi-regional support out of the box. This recipe explains how to benefit from Medusa's features to create a multi-regional store. + +--- + +## Multi-Region Setup + +In Medusa, you can create unlimited regions in your store. Each region has configurations managed through the Medusa Admin or the Admin REST APIs. + +### Currency + +Merchants specify the currency of each region. Multiple regions can have the same currency, but a region has only one currency. + +When customers view your products from a region, they see the prices in the region’s currency. + +### Tax rates and providers + +Merchants define a default tax rate and tax rates for specific conditions (for example, for a particular set of products) in a region. Each region also has a different tax provider that calculates the taxes. You can use Medusa's default tax provider or create a custom tax provider. + +During checkout, the taxes are calculated based on the customer's region, tax configurations, and rates. + +, + showLinkIcon: false, + badge: { + variant: "blue", + children: "Guide Soon" + } + }, + { + href: "!api!/admin#tax-regions_posttaxregions", + title: "Manage Taxes Using Admin APIs", + text: "Manage tax settings using the Admin APIs.", + startIcon: , + showLinkIcon: false + }, +]} /> + +} + showLinkIcon={false} + className="mt-1" +/> + + +### Payment and Fulfillment Providers + +Merchants choose which payment and fulfillment providers are available in each region. For example, one region can use Payment Provider A and B while another only uses Payment Provider B. + +During checkout, customers only see the payment providers and shipping options configured for the region. This lets you give customers a localized experience that feels familiar and instills trust. + +Medusa provides official provider modules for payment and fulfillment. You can also create custom provider modules. + +, + showLinkIcon: false, + badge: { + variant: "blue", + children: "Guide Soon" + } + }, + { + href: "/integrations", + title: "Integrations", + text: "Check out available integrations, including payment provider modules.", + startIcon: , + showLinkIcon: false + }, +]} /> + +--- + +## Prices Per Region and Currency + +Merchants sets the price of a product price per currency and region. This also applies to adding sales or overriding prices for specific conditions using the Pricing Module's features. + +Using the tax-inclusive feature, merchants can specify prices including taxes per currency and region. Medusa then calculates the tax amount applied to a line item in the cart based on the region's tax configurations. + +, + showLinkIcon: false, + badge: { + variant: "blue", + children: "Guide Soon" + } + }, + { + href: "#", + title: "Display Product Price in Storefront", + text: "Learn how to display the correct product price in a storefront.", + startIcon: , + showLinkIcon: false, + badge: { + variant: "blue", + children: "Guide Soon" + } + }, +]} /> + +--- + +## Multi-Warehouse Support + +Medusa's Inventory and Stock Location Modules provide multi-warehouse features that allow merchants to manage inventory across different locations. Merchants then control which location an item in an order is fulfilled, allowing them to keep a correct inventory count across locations and sales channels. + +A multi-regional setup lets merchants manage their inventory through Medusa across the different regions they serve. Customers are always shown accurate inventory information based on the location associated with their sales channel. + +{/* TODO add links */} + +, + showLinkIcon: false, + badge: { + variant: "blue", + children: "Guide Soon" + } + }, + { + href: "#", + title: "Manage Inventory", + text: "Learn how to manage inventory in the Medusa Admin.", + startIcon: , + showLinkIcon: false, + badge: { + variant: "blue", + children: "Guide Soon" + } + }, +]} /> + +--- + +## Multi-Lingual Setup + +By integrating a third-party Content Management Systems (CMS), you benefit from rich content features including managing your content in multiple languages. This allows you to cater to customers’ different languages in the regions you serve. + +To integrate a third-party system, create a custom module whose main service connects to that third-party service. + +} + showLinkIcon={false} +/> diff --git a/www/apps/resources/app/_recipes/omnichannel/page.mdx b/www/apps/resources/app/recipes/omnichannel/page.mdx similarity index 60% rename from www/apps/resources/app/_recipes/omnichannel/page.mdx rename to www/apps/resources/app/recipes/omnichannel/page.mdx index 57389de50a..890bfe7264 100644 --- a/www/apps/resources/app/_recipes/omnichannel/page.mdx +++ b/www/apps/resources/app/recipes/omnichannel/page.mdx @@ -7,7 +7,7 @@ export const metadata = { # {metadata.title} -In this recipe, you'll find resources to guide you in building an omnichannel commerce with Medusa. +This recipe provides the general steps to build an omnichannel store with Medusa. ## Overview @@ -21,26 +21,17 @@ Medusa’s modular architecture facilitates building omnichannel commerce, as yo ## Create Multiple Storefronts with One Server -when creating an omnichannel commerce, you build multiple webshops or mobile apps. All these storefronts should provide your customers with a similar experience, allow them to browse products, and place orders. +When creating an omnichannel commerce, you build multiple webshops or mobile apps. All these storefronts should provide your customers with a similar experience, allow them to browse products, and place orders. Medusa's commerce features are available as REST APIs that storefronts can access to provide these features for customers. This separation also gives you freedom in choosing the tech stack of the storefronts you’re creating. -, - showLinkIcon: false - }, - { - href: "#", - title: "Store REST APIs", - text: "Check out available Store REST APIs in Medusa.", - startIcon: , - showLinkIcon: false - }, -]} /> +} + showLinkIcon={false} +/> --- @@ -50,22 +41,22 @@ Businesses are no longer bound to sell in their stores. They can reach their cus One example is marketplaces like Amazon. Customers searching through Amazon to find products are inadvertently searching through many third-party stores connected to Amazon’s marketplace. -You can implement this example in Medusa with the Sales Channel Module and a custom plugin. Use the Sales Channel Module's features to set different product availability across sales channels. Then, in the custom plugin, integrate with Amazon’s seller program and use their APIs to push your products on Amazon. +You can implement this example in Medusa with the Sales Channel Module and a custom module. Use the Sales Channel Module's features to set different product availability across sales channels. Then, in the custom module, integrate with Amazon’s seller program and use their APIs to push your products on Amazon. -Another channel that attracts customer sales is social media. You can create a plugin that integrates with social media apps to show your products and sell them to customers. +Another channel that attracts customer sales is social media. You can create a custom module that integrates with social media apps to show your products and sell them to customers. , showLinkIcon: false }, { - href: "#", - title: "Create a Plugin", - text: "Learn how to create a plugin.", + href: "!docs!/basics/modules-and-services", + title: "Create a Module", + text: "Learn how to create a module.", startIcon: , showLinkIcon: false }, @@ -75,33 +66,27 @@ Another channel that attracts customer sales is social media. You can create a p ## Optimize Customer Experience -With Medusa’s modular architecture, businesses focus on bringing their brands to life without restrictions. - Implement the customer journey and design for your storefronts that leads to the best customer experience. The Medusa application doesn't impose any restrictions on how the frontend is built. -Medusa’s architecture also makes it easier to integrate any third-party services necessary to provide a better customer experience with a plugin. Storefronts then use the features provided by that plugin if they're exposed by API routes. +Medusa’s architecture also makes it easier to integrate any third-party services to provide a better customer experience: -For example, if you integrate a search plugin like Algolia in the Medusa application, the storefronts use it through the [Search Products API Route](https://docs.medusajs.com/api/store#products_postproductssearch). Another example is installing the Stripe payment plugin in the Medusa application and using it in your storefronts to make payments before placing an order. +- Create a module that integrates the third-party service. +- Expose the module's features in API routes. +- Send requests to these API routes from your storefront. , showLinkIcon: false }, { - href: "#", - title: "Plugins Library", - text: "Check out available community plugins.", - startIcon: , + href: "!docs!/basics/api-routes", + title: "Create an API Route", + text: "Learn how to create an API route.", + startIcon: , showLinkIcon: false }, ]} /> - ---- - -## Additional Development - -Refer to other guides in the Medusa Resources or the Medusa Book for additional guidance during your development. diff --git a/www/apps/resources/app/_recipes/oms/page.mdx b/www/apps/resources/app/recipes/oms/page.mdx similarity index 61% rename from www/apps/resources/app/_recipes/oms/page.mdx rename to www/apps/resources/app/recipes/oms/page.mdx index c0d5483324..723698fa7f 100644 --- a/www/apps/resources/app/_recipes/oms/page.mdx +++ b/www/apps/resources/app/recipes/oms/page.mdx @@ -7,7 +7,7 @@ export const metadata = { # {metadata.title} -In this recipe, you'll find resources to guide you in using Medusa as an Order Management System (OMS). +This recipe provides an overview of Medusa's features and how to use it as an Order Management System (OMS). ## Overview @@ -29,7 +29,7 @@ Sales channels in your commerce ecosystem must route their orders into the OMS. ![Routing orders into Medusa OMS](https://res.cloudinary.com/dza7lstvk/image/upload/v1709032160/Medusa%20Book/oms-orders_zf5ta9.jpg) -Medusa's [Store REST APIs](https://docs.medusajs.com/api/store) let you integrate a checkout experience in any storefront. Alternatively, you can use Medusa's [Draft Order APIs](https://docs.medusajs.com/api/admin#draft-orders) to place an order without direct involvement from the customer, such as when placing an order through a POS. +Medusa's [Store REST APIs](!api!/store) let you integrate a checkout experience in any storefront. Alternatively, you can use Medusa's [Draft Order APIs](!api!/admin#draft-orders) to place an order without direct involvement from the customer, such as when placing an order through a POS. In addition, you can customize the Medusa application to accept orders through a third-party checkout system. This gives you more flexibility over adding orders to Medusa. @@ -37,21 +37,21 @@ For example, you can support importing orders into Medusa through a custom API R , showLinkIcon: false }, { - href: "#", + href: "!docs!/basics/api-routes", title: "Create API Route", text: "Learn how to create a custom API Route.", startIcon: , showLinkIcon: false }, { - href: "#", + href: "!docs!/basics/scheduled-jobs", title: "Create Scheduled Jobs", text: "Learn how to create a scheduled job.", startIcon: , @@ -63,26 +63,32 @@ For example, you can support importing orders into Medusa through a custom API R ## Route Orders to Third-party Fulfillment Services -To integrate third-party fulfillment providers with the Medusa application, create a fulfillment service or use a fulfillment plugin. +To integrate third-party fulfillment providers with the Medusa application, create a fulfillment provider module. -Medusa uses the fulfillment service whenever a fulfillment action is performed, such as when a fulfillment is created for items in an order. The service's methods interact with the third-party provider to handle the desired fulfillment actions. +Medusa uses the Fulfillment Module whenever a fulfillment action is performed, such as when a fulfillment is created for items in an order. The module's main service's methods use the associated fulfillment provider module to handle the desired fulfillment actions. ![Fulfilling orders with Medusa OMS](https://res.cloudinary.com/dza7lstvk/image/upload/v1709032184/Medusa%20Book/oms-fulfillment_qfrpdd.jpg) -In addition, create a subscriber that listen to fulfillment-related events, such as the `order.fulfillment_created` event, to perform actions in the third-party fulfillment provider. +In addition, create a subscriber that listen to fulfillment-related events, such as the `order.fulfillment_created` event, to perform actions with the third-party fulfillment provider. + + + +Emitting of event `order.fulfillment_created` isn't implemented yet. + + , showLinkIcon: false }, { - href: "#", - title: "Events", - text: "Learn about the event bus and how to listen to events.", + href: "!docs!/basics/events-and-subscribers", + title: "Create a Subscriber", + text: "Learn about create a subscriber", startIcon: , showLinkIcon: false } @@ -92,22 +98,28 @@ In addition, create a subscriber that listen to fulfillment-related events, such ## Process Payment with Third-Party Providers -To integrate third-party payment providers with the Medusa application, create a payment processor or use payment plugins. +To integrate third-party payment providers with the Medusa application, create a payment provider module. In addition, create a subscriber that listen to payment-related events, such as the `order.payment_captured` event, to perform actions in the third-party payment provider. + + +Emitting of event `order.payment_captured` isn't implemented yet. + + + , + href: "/references/payment/provider", + title: "Create a Payment Provider Module", + text: "Learn how to create a payment provider module.", + startIcon: , showLinkIcon: false }, { - href: "#", - title: "Create a Payment Processor", - text: "Learn how to create a payment processor.", + href: "!docs!/basics/events-and-subscribers", + title: "Create a Subscriber", + text: "Learn about create a subscriber", startIcon: , showLinkIcon: false } @@ -123,71 +135,65 @@ When an order is placed, the item's quantity is reserved from the stock location , showLinkIcon: false }, { - href: "#", - title: "Sales Channels", - text: "Learn about the Sales Channel architecture and features.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Create a Plugin", - text: "Learn how to create a plugin.", + href: "/commerce-modules/stock-location", + title: "Stock Location Module", + text: "Learn about the Stock Location Module's concepts and features.", startIcon: , showLinkIcon: false } ]} /> +} + showLinkIcon={false} + className="mt-1" +/> + --- -## Handle Returns, Exchanges, and Edits +## Handle Returns, Exchanges, and Changes + + + +All types of order changes are coming soon. + + In Medusa, items in an order can be returned or exchanged; these can be created by the merchant or requested by the customer. A merchant can also edit an order to add, update, or delete items. -When changes are made to an order by any of the mentioned actions, the changes are reflected on the order's totals and associated inventory. The integrated fulfillment and payment providers are used if fulfillment or payment actions are required, such as fulfilling exchanged items. +When changes are made to an order by any of the mentioned actions, the changes are reflected on the order's totals and associated inventory. The integrated fulfillment and payment provider modules are used if fulfillment or payment actions are required, such as fulfilling exchanged items. Medusa also emits events related to these actions, such as `order.return_requested`. So, you can create a subscriber that listens to these events and perform asynchronous actions, such as communicating with third-party services. - + +Emitting of event `order.return_requested` isn't implemented yet. + + + +, showLinkIcon: false }, { - href: "#", - title: "Exchanges (Swaps)", - text: "Learn about the Exchange or Swap architecture and features.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Order Edits", - text: "Learn about the Order Edit feature and how it works.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Event Reference", - text: "Check out triggered events in Medusa and their payloads.", + href: "!docs!/basics/events-and-subscribers", + title: "Create a Subscriber", + text: "Learn about create a subscriber", startIcon: , showLinkIcon: false } ]} /> - ---- - -## Additional Development - -Refer to other guides in the Medusa Resources or the Medusa Book for additional guidance during your development. diff --git a/www/apps/resources/app/recipes/personalized-products/page.mdx b/www/apps/resources/app/recipes/personalized-products/page.mdx new file mode 100644 index 0000000000..c7a8f631c2 --- /dev/null +++ b/www/apps/resources/app/recipes/personalized-products/page.mdx @@ -0,0 +1,139 @@ +import { AcademicCapSolid, NextJs } from "@medusajs/icons" +import { LearningPath } from "docs-ui" + +export const metadata = { + title: `Personalized Products Recipe`, +} + +# {metadata.title} + +This recipe provides the general steps to build personalized products in Medusa. + +## Overview + +Personalized products are products that customers can customize based on their need. For example, they can upload an image to print on a shirt or provide a message to include in a letter. + +In Medusa, you create a custom module defining data models, implementing custom features, and creating relationships to other modules' data models. + +You also have freedom in how you choose to implement the storefront, allowing you to build a unique experience around your products. + +--- + +## Store Personalized Data + +The Cart Module's `LineItem` data model has a `metadata` field that holds any custom data. You can pass the customer's customization in that field when adding a product to the cart. + +For example, if you’re asking customers to enter a message to put in a letter they’re purchasing, use the `metadata` attribute of the `LineItem` data model to set the personalized information entered by the customer: + +```bash +curl -X POST '{backend_url}/store/carts/{id}/line-items' \ +-H 'Content-Type: application/json' \ +--data-raw '{ + "variant_id": "variant_123", + "quantity": 1, + "metadata": { + "message": "Hello, World!" + } +}' +``` + + + +Two line items in the cart having different `metadata` attributes are not considered the same item. So, each line item is managed separately and can have its own quantity. + + + +In more complex cases, you can create a custom module that stores and manages the personalization data models. You can also create a relationship between these data models and the `LineItem` data model. + +, + showLinkIcon: false + }, + { + href: "!docs!/basics/data-models", + title: "Create a Data Model", + text: "Learn how to create a data model.", + startIcon: , + showLinkIcon: false + } +]} /> + +--- + +## Build a Custom Storefront + +Due to Medusa's modular architecture, there are no restrictions on the framework you use to build the storefront, or design and experience you provide customers. The storefront connects to the Medusa application using the Store API Routes. + +You can build a unique experience around your products that focuses on the customer’s personalization capabilities. + +Medusa provides a Next.js Starter storefront with basic ecommerce functionalities that can be customized. You can also build your own storefront and use Medusa’s client libraries or Store API Routes to communicate with the Medusa application. + +, + showLinkIcon: false + }, + { + href: "!docs!/storefront-development/tips", + title: "Build Your Own Storefront", + text: "Find tips on how to create a storefront.", + startIcon: , + showLinkIcon: false + }, +]} /> + +--- + +## Pass Personalized Data to the Order + +If you store the personalized data using a custom module: + +- Create a custom API Route that handles saving the personalization data. +- Call that API Route from the storefront after adding the item to the cart. +- Listen to the `order.placed` event to attach the personalized data to the Order Module's `LineItem` data model. + + + +The `order.placed` event is currently not emitted. + + + +, + showLinkIcon: false + }, + { + href: "!docs!/basics/events-and-subscribers", + title: "Create a Subscriber", + text: "Learn how to create a subscriber.", + startIcon: , + showLinkIcon: false + }, +]} /> + +--- + +## Fulfill Personalized Products in Orders + +To fulfill your personalized products with a third-party service or custom logic, create a fulfillment provider module. + +The Fulfillment Module registers your fulfillment provider module to use it to fulfill orders. + +} + showLinkIcon={false} +/> diff --git a/www/apps/resources/app/_recipes/pos/page.mdx b/www/apps/resources/app/recipes/pos/page.mdx similarity index 66% rename from www/apps/resources/app/_recipes/pos/page.mdx rename to www/apps/resources/app/recipes/pos/page.mdx index 5ece8b407d..6a5c977e6c 100644 --- a/www/apps/resources/app/_recipes/pos/page.mdx +++ b/www/apps/resources/app/recipes/pos/page.mdx @@ -7,7 +7,7 @@ export const metadata = { # {metadata.title} -In this recipe, you'll find resources to guide you in using Medusa as an Point-of-Sale (POS). +This recipe provides the general steps to build a Point of Sale (POS) system with Medusa. ## Overview @@ -29,39 +29,23 @@ When you build a POS system, you must choose which programming framework, langua Medusa's modular architecture removes any restrictions you may have while making this choice. Any client or front end can connect to the Medusa application using its headless REST APIs. -![POS Tech Stack](https://res.cloudinary.com/dza7lstvk/image/upload/v1709034282/Medusa%20Book/pos-scan-barcode_a8j8ew.jpg) +![POS Tech Stack](https://res.cloudinary.com/dza7lstvk/image/upload/v1709034046/Medusa%20Book/pos-tech-stack_fy8uiu.jpg) -, - showLinkIcon: false - }, - { - href: "https://docs.medusajs.com/api/admin", - title: "Admin REST APIs", - text: "Check out available Admin REST APIs in Medusa.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "JavaScript Client", - text: "Learn about the JavaScript client and to use it.", - startIcon: , - showLinkIcon: false - } -]} /> +} + showLinkIcon={false} +/> --- ## Integrate a Barcode Scanner -POS systems make the checkout process smoother by integrating a barcode scanner. Merchants can scan a product by its barcode to check its details or add it to the customer's purchase. +POS systems make the checkout process smoother by integrating a barcode scanner. Merchants scan a product by its barcode to check its details or add it to the customer's purchase. -Medusa’s `ProductVariant` data model includes the necessary attributes to implement this integration, mainly the `barcode` attribute. Other notable attributes include `ean`, `upc`, and `hs_code`, among others. +The Product Module's `ProductVariant` data model has the fields to implement this integration, mainly the `barcode` attribute. Other notable fields include `ean`, `upc`, and `hs_code`, among others. To search through product variants by their barcode, create a custom API Route and call it within your POS. @@ -88,13 +72,14 @@ To search through product variants by their barcode, create a custom API Route a Here’s an example of creating a custom API Route at `/store/pos/search-barcode` that searches product variants by a barcode: - ```ts title="src/api/store/pos/search-barcode/route.ts" + ```ts title="src/api/store/pos/search-barcode/route.ts" collapsibleLines="1-8" expandButtonLabel="Show Imports" import type { MedusaRequest, MedusaResponse, - ProductVariantService, } from "@medusajs/medusa" import { MedusaError } from "@medusajs/utils" + import { ModuleRegistrationName } from "@medusajs/modules-sdk" + import { IProductModuleService } from "@medusajs/types" export const GET = async ( req: MedusaRequest, @@ -108,13 +93,15 @@ To search through product variants by their barcode, create a custom API Route a ) } // get product service - const productVariantService = req.scope.resolve< - ProductVariantService - >("productVariantService") + const productModuleService: IProductModuleService = + req.scope.resolve( + ModuleRegistrationName.PRODUCT + ) // retrieve product variants by barcode - const productVariants = await productVariantService - .list({ + const productVariants = await productModuleService + .listVariants({ + // @ts-ignore barcode, }) @@ -142,21 +129,21 @@ This also opens the door for other business opportunities, such as an endless ai , showLinkIcon: false }, { - href: "#", + href: "/commerce-modules/stock-location", title: "Stock Location Module", text: "Learn about the Stock Location Module.", startIcon: , showLinkIcon: false }, { - href: "#", + href: "/commerce-modules/sales-channel", title: "Sales Channel Module", text: "Learn about the Sales Channel Module.", startIcon: , @@ -170,22 +157,29 @@ This also opens the door for other business opportunities, such as an endless ai Using Medusa's Customer Module, you can retrieve a customer's details from the Medusa application and place an order on the POS under their account. The customer can then view their order details on their online profile as if they had placed the order online. -In addition, using Medusa's Discount Module, store operators can create discounts on the fly for customers using the POS system and apply them to their orders. +In addition, using Medusa's Promotion Module, store operators can create promotions on the fly for customers using the POS system and apply them to their orders. -You can also extend Medusa to provide features for a better customer experience, such as a rewards system or loyalty points. +You can also create custom modules to provide features for a better customer experience, such as a rewards system or loyalty points. -, showLinkIcon: false }, { - href: "#", - title: "Discount Module", - text: "Learn about the Discount Module.", + href: "/commerce-modules/promotion", + title: "Promotion Module", + text: "Learn about the Promotion Module.", + startIcon: , + showLinkIcon: false + }, + { + href: "!docs!/basics/modules-and-services", + title: "Create a Module", + text: "Learn how to create a module.", startIcon: , showLinkIcon: false }, @@ -195,38 +189,25 @@ You can also extend Medusa to provide features for a better customer experience, ## Accept Payment, Place Order, and Use RMA Features -Medusa's architecture allows you to integrate any third-party payment processor for your POS and online storefront. For example, you can integrate [Stripe Terminal](https://stripe.com/terminal) to accept in-store payments. +Medusa's architecture allows you to integrate any third-party payment provider for your POS and online storefront. For example, you can integrate [Stripe Terminal](https://stripe.com/terminal) to accept in-store payments. -Once you accept the payment, place an order in the POS system using the [Draft Order APIs](https://docs.medusajs.com/api/admin#draft-orders). Draft orders provide similar features to an online checkout experience, including discounts, payment processing, and more. +Once you accept the payment, place an order in the POS system using the [Draft Order APIs](!api!/admin#draft-orders). Draft orders provide similar features to an online checkout experience, including discounts, payment processing, and more. -Then, merchants can view all orders coming from different sales channels using the Medusa Admin. This keeps logistics and order handling consistent and allows businesses to provide return and exchange features to online and in-store customers. +Then, merchants can view all orders coming from different sales channels using the Medusa Admin. This keeps logistics and order handling consistent between online and in-store customers. , showLinkIcon: false }, { - href: "#", - title: "Draft Order Module", - text: "Learn about the Draft Order module.", - startIcon: , - showLinkIcon: false - }, - { - href: "#", - title: "Order Module", - text: "Learn about the Order Module.", + href: "!api!/admin", + title: "Admin REST APIs", + text: "Check out available Admin REST APIs.", startIcon: , showLinkIcon: false }, ]} /> - ---- - -## Additional Development - -Refer to other guides in the Medusa Resources or the Medusa Book for additional guidance during your development. diff --git a/www/apps/resources/app/_recipes/subscriptions/page.mdx b/www/apps/resources/app/recipes/subscriptions/page.mdx similarity index 54% rename from www/apps/resources/app/_recipes/subscriptions/page.mdx rename to www/apps/resources/app/recipes/subscriptions/page.mdx index d4e1325682..6c8243c595 100644 --- a/www/apps/resources/app/_recipes/subscriptions/page.mdx +++ b/www/apps/resources/app/recipes/subscriptions/page.mdx @@ -7,7 +7,7 @@ export const metadata = { # {metadata.title} -In this recipe, you'll find resources to guide you in building subscription-based purchases with Medusa. +This recipe provides the general steps to build subscription-based purchase with Medusa. ## Overview @@ -21,38 +21,34 @@ For example, a customer can purchase a book subscription box for a period of thr - - --- -## Save Subscription Details in the Database +## Save Subscription Details Subscriptions have details related to the subscription interval, subscription period, and more. -Based on the approach you choose to implement the subscription logic (which is discussed in the next section), you may need to store different data in your Medusa application. +To store the subscription details, create a data model in a module. The module's main service provides data management feature of the data model. -To store the subscription details in a new table in the database, create a data model. You can also extend an existing data model in Medusa's commerce modules, such as the `Order` data model, to add details related to the subscription. - -, showLinkIcon: false }, { - href: "#", - title: "Extend a Data Model", - text: "Learn how to extend a data model in Medusa.", + href: "!docs!/basics/data-models", + title: "Create a Data Model", + text: "Learn how to create a data model.", startIcon: , showLinkIcon: false - } + }, ]} /> --- -## Decide on Subscription Approach +## Implement Subscription Approach There are different ways to implement subscriptions in your Medusa application. This recipe provides two options: using Stripe subscriptions, or implementing subscriptions logic within the application, independent of a specific payment provider. @@ -62,46 +58,44 @@ Stripe provides a [subscription payments feature](https://stripe.com/docs/billin This approach allows you to delegate the complications of implementing the subscription logic to Stripe, but doesn't support using other payment providers. -Although Medusa provides a Stripe plugin, it doesn't handle subscriptions. You can either use that plugin to add the subscription feature on top of it, or create a custom Stripe Subscription payment provider. +Although Medusa provides a Stripe provider module, it doesn't handle subscriptions. You can create a custom Stripe Subscription payment module. -, - showLinkIcon: false - }, - { - href: "#", - title: "Create Payment Processor", - text: "Create a Stripe Subscription payment processor from scratch.", - startIcon: , - showLinkIcon: false - } -]} /> +} + showLinkIcon={false} +/> -### Option 2: Implement Subscription Logic +### Option 2: Custom Subscription Logic -By implementing the subscription logic within your application, you have full control over the subscription logic. You'll also be independent of payment providers, providing customers with more than one payment provider option. +By implementing the subscription logic within your application, you have full control over the subscription logic. You'll also be independent of payment providers, providing customers with more than one payment provider. Implementing the logic depends on your use case, but you'll mainly implement the following: 1. Create a subscriber that listens to the `order.placed` event to save the subscription details or perform other actions. 2. Create a scheduled job that checks daily for subscriptions that need renewal. + + +- The `order.placed` event isn't emitted yet. +- Scheduled jobs are coming soon. + + + , showLinkIcon: false }, { - href: "#", + href: "!docs!/basics/scheduled-jobs", title: "Create a Scheduled Job", - text: "Learn how to create a scheduled job in Medusa.", + text: "Learn how to create a scheduled job.", startIcon: , showLinkIcon: false } @@ -111,31 +105,24 @@ Implementing the logic depends on your use case, but you'll mainly implement the ## Customize Admin -You can customize the admin to provide an interface to manage your custom features. +You can extend the admin to provide an interface to manage your custom features. -You can extend the Medusa Admin plugin to add widgets to existing pages, add new pages, or add setting pages. +Extend the Medusa Admin to add widgets to existing pages or add new pages. , showLinkIcon: false }, { - href: "#", + href: "!docs!/advanced-development/admin/ui-routes", title: "Create Admin UI Routes", text: "Learn how to add new pages to your Medusa Admin.", startIcon: , showLinkIcon: false - }, - { - href: "#", - title: "Create Admin Setting Page", - text: "Learn how to add new page to the Medusa Admin settings.", - startIcon: , - showLinkIcon: false } ]} /> @@ -145,39 +132,19 @@ You can extend the Medusa Admin plugin to add widgets to existing pages, add new Medusa provides a Next.js Starter. Since you've customized your Medusa project, you must either customize the existing Next.js Starter, or create a custom storefront. -, showLinkIcon: false }, { - href: "#", + href: "!docs!/storefront-development/tips", title: "Option 2: Build Custom Storefront", - text: "Find useful resources to build your own storefront.", + text: "Find tips to build your own storefront.", startIcon: , showLinkIcon: false } ]} /> - ---- - -## Deploy Medusa Application - -The Medusa Resources documentation includes deployment guides for a basic Medusa application. You can follow it to deploy your customized marketplace, as well. - -} - showLinkIcon={false} -/> - ---- - -## Additional Development - -Refer to other guides in the Medusa Resources or the Medusa Book for additional guidance during your development. diff --git a/www/apps/resources/generated/files-map.mjs b/www/apps/resources/generated/files-map.mjs index b8763e4fee..8d0af1b1ce 100644 --- a/www/apps/resources/generated/files-map.mjs +++ b/www/apps/resources/generated/files-map.mjs @@ -99,54 +99,6 @@ export const filesMap = [ "filePath": "/www/apps/resources/app/_plugins/storage/spaces/page.mdx", "pathname": "/_plugins/storage/spaces" }, - { - "filePath": "/www/apps/resources/app/_recipes/b2b/page.mdx", - "pathname": "/_recipes/b2b" - }, - { - "filePath": "/www/apps/resources/app/_recipes/commerce-automation/page.mdx", - "pathname": "/_recipes/commerce-automation" - }, - { - "filePath": "/www/apps/resources/app/_recipes/digital-products/page.mdx", - "pathname": "/_recipes/digital-products" - }, - { - "filePath": "/www/apps/resources/app/_recipes/ecommerce/page.mdx", - "pathname": "/_recipes/ecommerce" - }, - { - "filePath": "/www/apps/resources/app/_recipes/integrate-ecommerce-stack/page.mdx", - "pathname": "/_recipes/integrate-ecommerce-stack" - }, - { - "filePath": "/www/apps/resources/app/_recipes/marketplace/page.mdx", - "pathname": "/_recipes/marketplace" - }, - { - "filePath": "/www/apps/resources/app/_recipes/multi-region-store/page.mdx", - "pathname": "/_recipes/multi-region-store" - }, - { - "filePath": "/www/apps/resources/app/_recipes/omnichannel/page.mdx", - "pathname": "/_recipes/omnichannel" - }, - { - "filePath": "/www/apps/resources/app/_recipes/oms/page.mdx", - "pathname": "/_recipes/oms" - }, - { - "filePath": "/www/apps/resources/app/_recipes/personalized-products/page.mdx", - "pathname": "/_recipes/personalized-products" - }, - { - "filePath": "/www/apps/resources/app/_recipes/pos/page.mdx", - "pathname": "/_recipes/pos" - }, - { - "filePath": "/www/apps/resources/app/_recipes/subscriptions/page.mdx", - "pathname": "/_recipes/subscriptions" - }, { "filePath": "/www/apps/resources/app/admin-widget-injection-zones/page.mdx", "pathname": "/admin-widget-injection-zones" @@ -867,6 +819,54 @@ export const filesMap = [ "filePath": "/www/apps/resources/app/page.mdx", "pathname": "/" }, + { + "filePath": "/www/apps/resources/app/recipes/b2b/page.mdx", + "pathname": "/recipes/b2b" + }, + { + "filePath": "/www/apps/resources/app/recipes/commerce-automation/page.mdx", + "pathname": "/recipes/commerce-automation" + }, + { + "filePath": "/www/apps/resources/app/recipes/digital-products/page.mdx", + "pathname": "/recipes/digital-products" + }, + { + "filePath": "/www/apps/resources/app/recipes/ecommerce/page.mdx", + "pathname": "/recipes/ecommerce" + }, + { + "filePath": "/www/apps/resources/app/recipes/integrate-ecommerce-stack/page.mdx", + "pathname": "/recipes/integrate-ecommerce-stack" + }, + { + "filePath": "/www/apps/resources/app/recipes/marketplace/page.mdx", + "pathname": "/recipes/marketplace" + }, + { + "filePath": "/www/apps/resources/app/recipes/multi-region-store/page.mdx", + "pathname": "/recipes/multi-region-store" + }, + { + "filePath": "/www/apps/resources/app/recipes/omnichannel/page.mdx", + "pathname": "/recipes/omnichannel" + }, + { + "filePath": "/www/apps/resources/app/recipes/oms/page.mdx", + "pathname": "/recipes/oms" + }, + { + "filePath": "/www/apps/resources/app/recipes/personalized-products/page.mdx", + "pathname": "/recipes/personalized-products" + }, + { + "filePath": "/www/apps/resources/app/recipes/pos/page.mdx", + "pathname": "/recipes/pos" + }, + { + "filePath": "/www/apps/resources/app/recipes/subscriptions/page.mdx", + "pathname": "/recipes/subscriptions" + }, { "filePath": "/www/apps/resources/app/references/[...slug]/page.tsx", "pathname": "/references/[...slug]" diff --git a/www/apps/resources/generated/sidebar.mjs b/www/apps/resources/generated/sidebar.mjs index 4cf9971c31..62549a363f 100644 --- a/www/apps/resources/generated/sidebar.mjs +++ b/www/apps/resources/generated/sidebar.mjs @@ -5827,6 +5827,98 @@ export const generatedSidebar = [ } ] }, + { + "loaded": true, + "isPathHref": true, + "title": "Recipes", + "hasTitleStyling": true, + "children": [ + { + "loaded": true, + "isPathHref": true, + "path": "/recipes/b2b", + "title": "B2B", + "children": [] + }, + { + "loaded": true, + "isPathHref": true, + "path": "/recipes/commerce-automation", + "title": "Commerce Automation", + "children": [] + }, + { + "loaded": true, + "isPathHref": true, + "path": "/recipes/digital-products", + "title": "Digital Products", + "children": [] + }, + { + "loaded": true, + "isPathHref": true, + "path": "/recipes/ecommerce", + "title": "Ecommerce", + "children": [] + }, + { + "loaded": true, + "isPathHref": true, + "path": "/recipes/integrate-ecommerce-stack", + "title": "Integrate Ecommerce Stack", + "children": [] + }, + { + "loaded": true, + "isPathHref": true, + "path": "/recipes/marketplace", + "title": "Marketplace", + "children": [] + }, + { + "loaded": true, + "isPathHref": true, + "path": "/recipes/multi-region-store", + "title": "Multi-Region Store", + "children": [] + }, + { + "loaded": true, + "isPathHref": true, + "path": "/recipes/omnichannel", + "title": "Omnichannel Store", + "children": [] + }, + { + "loaded": true, + "isPathHref": true, + "path": "/recipes/oms", + "title": "OMS", + "children": [] + }, + { + "loaded": true, + "isPathHref": true, + "path": "/recipes/personalized-products", + "title": "Personalized Products", + "children": [] + }, + { + "loaded": true, + "isPathHref": true, + "path": "/recipes/pos", + "title": "POS", + "children": [] + }, + { + "loaded": true, + "isPathHref": true, + "path": "/recipes/subscriptions", + "title": "Subscriptions", + "children": [] + } + ] + }, { "loaded": true, "isPathHref": true, diff --git a/www/apps/resources/sidebar.mjs b/www/apps/resources/sidebar.mjs index 2b2db6fe7b..7fd6f163c0 100644 --- a/www/apps/resources/sidebar.mjs +++ b/www/apps/resources/sidebar.mjs @@ -1279,6 +1279,61 @@ export const sidebar = sidebarAttachHrefCommonOptions([ }, ], }, + + { + title: "Recipes", + hasTitleStyling: true, + children: [ + { + path: "/recipes/b2b", + title: "B2B", + }, + { + path: "/recipes/commerce-automation", + title: "Commerce Automation", + }, + { + path: "/recipes/digital-products", + title: "Digital Products", + }, + { + path: "/recipes/ecommerce", + title: "Ecommerce", + }, + { + path: "/recipes/integrate-ecommerce-stack", + title: "Integrate Ecommerce Stack", + }, + { + path: "/recipes/marketplace", + title: "Marketplace", + }, + { + path: "/recipes/multi-region-store", + title: "Multi-Region Store", + }, + { + path: "/recipes/omnichannel", + title: "Omnichannel Store", + }, + { + path: "/recipes/oms", + title: "OMS", + }, + { + path: "/recipes/personalized-products", + title: "Personalized Products", + }, + { + path: "/recipes/pos", + title: "POS", + }, + { + path: "/recipes/subscriptions", + title: "Subscriptions", + }, + ], + }, { title: "SDKs and Tools", hasTitleStyling: true, diff --git a/www/packages/docs-ui/src/components/Card/index.tsx b/www/packages/docs-ui/src/components/Card/index.tsx index de30f18e18..174ca5ddf2 100644 --- a/www/packages/docs-ui/src/components/Card/index.tsx +++ b/www/packages/docs-ui/src/components/Card/index.tsx @@ -1,7 +1,7 @@ import React from "react" import { ArrowUpRightOnBox } from "@medusajs/icons" import clsx from "clsx" -import { Link } from "@/components" +import { Badge, BadgeProps, Link } from "@/components" export type CardProps = { startIcon?: React.ReactNode @@ -14,6 +14,7 @@ export type CardProps = { children?: React.ReactNode showLinkIcon?: boolean isExternal?: boolean + badge?: BadgeProps } export const Card = ({ @@ -27,6 +28,7 @@ export const Card = ({ children, showLinkIcon = true, isExternal = false, + badge, }: CardProps) => { return (
{title && ( - - {title} + + + {title} + + {badge && } )} {text && ( diff --git a/www/packages/docs-ui/src/components/CardList/index.tsx b/www/packages/docs-ui/src/components/CardList/index.tsx index 0af679435a..cf34d027d4 100644 --- a/www/packages/docs-ui/src/components/CardList/index.tsx +++ b/www/packages/docs-ui/src/components/CardList/index.tsx @@ -5,9 +5,10 @@ import clsx from "clsx" type CardListProps = { items: CardProps[] itemsPerRow?: number + className?: string } -export const CardList = ({ items, itemsPerRow }: CardListProps) => { +export const CardList = ({ items, itemsPerRow, className }: CardListProps) => { if (!itemsPerRow) { // if length of items is even, set to `2`, else set to `3` itemsPerRow = items.length === 1 ? 0 : items.length % 2 === 0 ? 2 : 3 @@ -18,7 +19,8 @@ export const CardList = ({ items, itemsPerRow }: CardListProps) => { "grid gap-x-docs_1 auto-rows-fr gap-y-docs_1", itemsPerRow === 1 && "grid-cols-1", itemsPerRow === 2 && "md:grid-cols-2 grid-cols-1", - itemsPerRow === 3 && "lg:grid-cols-3 md:grid-col-2 grid-cols-1" + itemsPerRow === 3 && "lg:grid-cols-3 md:grid-col-2 grid-cols-1", + className )} > {items.map((item, key) => (