Files
medusa-store/www/apps/docs/content/plugins/cms/contentful.md
Shahed Nasser 6d9c0eb70b docs: fix usage of non-existing repository in digital products recipe (#5280)
* docs: fix usage of non-existing repository in digital products recipe

* fix content linting
2023-10-04 11:05:23 +03:00

15 KiB
Raw Blame History

addHowToData
addHowToData
true

Contentful Plugin

In this document, youll learn how to install and use the Contentful plugin.

Overview

Contentful is a headless CMS service that allows developers to integrate rich CMS functionalities into any platform.

By integrating Contentful to Medusa, you can benefit from powerful features in your ecommerce store including detailed product CMS details, easy-to-use interface to use for static content and pages, localization, and much more. The data is also automatically synced between Contentful and your Medusa store.


Prerequisites

Medusa Components

This guide assumes you already have a Medusa backend installed. If not, you can learn how to install it here.

Redis must also be configured in your Medusa backend if you want to sync data from Medusa to Contentful. Learn how to configure it here.

Needed Account

  • Contentful account with a space created. A space is created by default when you create a new account.

Install Plugin

In the directory of your Medusa backend, run the following command to install the Contentful plugin:

npm install medusa-plugin-contentful

Next, add the plugin into the plugins array in medusa-config.js:

const plugins = [
  // ...
  {
    resolve: `medusa-plugin-contentful`,
    options: {
      space_id: process.env.CONTENTFUL_SPACE_ID,
      access_token: process.env.CONTENTFUL_ACCESS_TOKEN,
      environment: process.env.CONTENTFUL_ENV,
    },
  },
]

Plugin Options

The plugin accepts the following options:

  1. space_id: (required) a string indicating the ID of your Contentful space. Refer to Contentfuls documentation if youre unsure where to find it.
  2. access_token: (required) a string indicating the personal access token for content management. Refer to Contentfuls documentation to learn how to create it.
  3. environment: (required) a string indicating the Contentful environment. Typically, its value should be master.
  4. ignore_threshold: (optional) a number indicating the number of seconds to wait before re-syncing a specific record. By default, its value is 2.
  5. custom_<TYPE>_fields: (optional) an object that allows you to map fields in Medusa to custom field names. Learn more here.

Make sure to add required values as environment variables:

CONTENTFUL_SPACE_ID=<YOUR_SPACE_ID>
CONTENTFUL_ACCESS_TOKEN=<YOUR_ACCESS_TOKEN>
CONTENTFUL_ENV=master

Custom Field Mapping

When the plugin syncs data between Contentful and Medusa, it expects a set of fields to be defined in the respective content models in Contentful. If you choose to use different names to define those fields in Contentful, you have to specify them in the custom_<TYPE>_fields option mentioned earlier, where <TYPE> is the name of the content model.

For example, to change the name of the products title field, pass the following option to the plugin:

const plugins = [
  // ...
  {
    resolve: `medusa-plugin-contentful`,
    options: {
      space_id: process.env.CONTENTFUL_SPACE_ID,
      access_token: process.env.CONTENTFUL_ACCESS_TOKEN,
      environment: process.env.CONTENTFUL_ENV,
      custom_product_fields: {
        title: "name",
      },
    },
  },
]

The rest of this section includes the field names you can customize using this option for each content model type.

product
  • title
  • subtitle
  • description
  • variants
  • options
  • medusaId
  • type
  • collection
  • tags
  • handle
variant
  • title
  • sku
  • prices
  • options
  • medusaId
region
  • name
  • countries
  • paymentProviders
  • fulfillmentProviders
  • medusaId
collection
  • title
  • medusaId
type
  • name
  • medusaId

Migrate Content Models

In your Contentful space, you must have content models for Medusa entities such as products and regions.

You can either create the content models manually, or you can write a migration script in the Medusa backend that migrates these content models into Contentful.

This section includes migration scripts for Medusas entities that are relevant for Contentful. You can customize the scripts if necessary. You can also create scripts for custom content models, such as a Link or Navigation Item content model.

Before creating the migration scripts, run the following command in the root of your Medusa backend to install Contentfuls migration SDK:

npm install --save-dev contentful-migration
product Content Model

Create the file src/loaders/contentful-migrations/product.ts with the following content:

import Migration, { 
  MigrationContext,
} from "contentful-migration"

export function productMigration(
  migration: Migration,
  context?: MigrationContext
) {
  const product = migration
    .createContentType("product")
    .name("Product")
    .displayField("title")

  product
    .createField("title")
    .name("Title")
    .type("Symbol")
    .required(true)
  product
    .createField("subtitle")
    .name("Subtitle")
    .type("Symbol")
  product
    .createField("handle")
    .name("Handle")
    .type("Symbol")
  product
    .createField("thumbnail")
    .name("Thumbnail")
    .type("Link")
    .linkType("Asset")
  product
    .createField("description")
    .name("Description")
    .type("Text")
  product
    .createField("options")
    .name("Options")
    .type("Object")
  product
    .createField("tags")
    .name("Tags")
    .type("Object")
  product
    .createField("collection")
    .name("Collection")
    .type("Symbol")
  product
    .createField("type")
    .name("Type")
    .type("Symbol")
  product
    .createField("variants")
    .name("Variants")
    .type("Array")
    .items({
      type: "Link",
      linkType: "Entry",
      validations: [
        {
          linkContentType: ["productVariant"],
        },
      ],
    })
  product
    .createField("medusaId")
    .name("Medusa ID")
    .type("Symbol")
}
productVariant Content Model

Create the file src/loaders/contentful-migrations/product-variant.ts with the following content:

import Migration, { 
  MigrationContext,
} from "contentful-migration"

export function productVariantMigration(
  migration: Migration,
  context?: MigrationContext
) {
  const productVariant = migration
    .createContentType("productVariant")
    .name("Product Variant")
    .displayField("title")

  productVariant
    .createField("title")
    .name("Title")
    .type("Symbol")
    .required(true)
  productVariant
    .createField("sku")
    .name("SKU")
    .type("Symbol")
  productVariant
    .createField("options")
    .name("Options")
    .type("Object")
  productVariant
    .createField("prices")
    .name("Prices")
    .type("Object")
  productVariant
    .createField("medusaId")
    .name("Medusa ID")
    .type("Symbol")
}
collection Content Model

Create the file src/loaders/contentful-migrations/product-collection.ts with the following content:

import Migration, { 
  MigrationContext,
} from "contentful-migration"

export function productCollectionMigration(
  migration: Migration,
  context?: MigrationContext
) {
  const collection = migration
    .createContentType("collection")
    .name("Product Collection")
    .displayField("title")

    collection
      .createField("title")
      .name("Title")
      .type("Symbol")
      .required(true)
    collection
      .createField("medusaId")
      .name("Medusa ID")
      .type("Symbol")
}
productType Content Model

Create the file src/loaders/contentful-migrations/product-type.ts with the following content:

import Migration, { 
  MigrationContext,
} from "contentful-migration"

export function productTypeMigration(
  migration: Migration,
  context?: MigrationContext
) {
  const collection = migration
    .createContentType("productType")
    .name("Product Type")
    .displayField("title")

    collection
      .createField("title")
      .name("Title")
      .type("Symbol")
      .required(true)
    collection
      .createField("medusaId")
      .name("Medusa ID")
      .type("Symbol")
}
region Content Model

Create the file src/loaders/contentful-migrations/region.ts with the following content:

import Migration, { 
  MigrationContext,
} from "contentful-migration"

export function regionMigration(
  migration: Migration,
  context?: MigrationContext
) {
  const region = migration
    .createContentType("region")
    .name("Region")
    .displayField("name")

  region
    .createField("name")
    .name("Name")
    .type("Symbol")
    .required(true)
  region
    .createField("countries")
    .name("Options")
    .type("Object")
  region
    .createField("paymentProviders")
    .name("Payment Providers")
    .type("Object")
  region
    .createField("fulfillmentProviders")
    .name("Fulfillment Providers")
    .type("Object")
  region
    .createField("currencyCode")
    .name("Currency Code")
    .type("Symbol")
  region
    .createField("medusaId")
    .name("Medusa ID")
    .type("Symbol")
}

Finally, create a loader at src/loaders/index.ts with the following content:

import { ConfigModule, StoreService } from "@medusajs/medusa"
import { AwilixContainer } from "awilix"
import { runMigration } from "contentful-migration"
import { 
  productMigration,
} from "./contentful-migrations/product"
import { 
  productVariantMigration,
} from "./contentful-migrations/product-variant"
import { 
  productCollectionMigration,
} from "./contentful-migrations/product-collection"
import { 
  productTypeMigration,
} from "./contentful-migrations/product-type"
import { 
  regionMigration,
} from "./contentful-migrations/region"

type ContentfulPluginType = {
  resolve: string
  options: {
    space_id: string
    access_token: string
    environment: string
  }
}

export default async (
  container: AwilixContainer,
  config: ConfigModule
): Promise<void> => {
  // ensure that migration only runs once
  const storeService = container.resolve<StoreService>(
    "storeService"
  )
  const store = await storeService.retrieve()

  if (store.metadata?.ran_contentful_migrations) {
    return
  }

  console.info("Running contentful migrations...")
  
  // load Contentful options
  const contentfulPlugin = config.plugins
    .find((plugin) => 
      typeof plugin === "object" && 
      plugin.resolve === "medusa-plugin-contentful"
    ) as ContentfulPluginType

  if (!contentfulPlugin) {
    console.log(
      "Didn't find Contentful plugin. Aborting migration..."
    )
    return
  }

  const options = {
    spaceId: contentfulPlugin.options.space_id,
    accessToken: contentfulPlugin.options.access_token,
    environment: contentfulPlugin.options.environment,
    yes: true,
  }

  const migrationFunctions = [
    {
      name: "Product",
      function: productMigration,
    },
    {
      name: "Product Variant",
      function: productVariantMigration,
    },
    {
      name: "Product Collection",
      function: productCollectionMigration,
    },
    {
      name: "Product Type",
      function: productTypeMigration,
    },
    {
      name: "Region",
      function: regionMigration,
    },
  ]

  await Promise.all(
    migrationFunctions.map(async (migrationFunction) => {
      console.info(`Migrating ${
        migrationFunction.name
      } component...`)
      try {
        await runMigration({
          ...options,
          migrationFunction: migrationFunction.function,
        })
        console.info(`Finished migrating ${
          migrationFunction.name
        } component`)
      } catch (e) {
        if (
          typeof e === "object" && "errors" in e &&
          Array.isArray(e.errors) && 
          e.errors.length > 0 && 
          e.errors[0].type === "Invalid Action" && 
          e.errors[0].message.includes("already exists")
        ) {
          console.info(`${
            migrationFunction.name
          } already exists. Skipping its migration.`)
        } else {
          throw new Error(e)
        }
      }
    })
  )

  await storeService.update({
    metadata: {
      ran_contentful_migrations: true,
    },
  })

  console.info("Finished contentful migrations")
}

Notice that in the script you store a flag in the default stores metadata attribute to ensure these migrations only run once.

Setup Webhooks

As mentioned in the introduction, this plugin supports two-way sync. A subscriber listens to changes in the data, such as adding a new product, and syncs the data with Contentful.

To update the Medusa backend when changes occur in Contentful, you must configure webhooks settings in Contentful.

:::note

For webhooks to work, your backend must be deployed and accessible publicly. If you havent deployed your backend, refer to these deployment guides.

:::

To do that:

  1. On your Contentful Space Dashboard, click on Settings from the navigation bar, then choose Webhooks.
  2. Click on the Add Webhook button.
  3. In the form, enter a name for the webhook.
  4. In the URL field, choose the method POST and in the input next to it enter the URL <BACKEND_URL>/hooks/contentful where <BACKEND_URL> is the URL of your deployed Medusa backend.
  5. Scroll down to find the Content Type select field. Choose application/json as its value.
  6. You can leave the rest of the fields the same and click on the Save button.

Test the Plugin

Run the following command to start your Medusa backend and test the plugin:

npx medusa develop

If you created migration scripts, theyll run when the Medusa backend first starts and migrate your content models to Contentful. You can go to your spaces dashboard to confirm theyve been created.

After that, try the sync functionality by creating or updating products in the Medusa backend. If youve also setup webhooks, you can test out the sync from Contentful to Medusa.

:::note

As mentioned in the Prerequisites section, you must configure Redis for the Medusa to Contentful sync to work.

:::


Whats Next?

After installing the plugin, you can either customize the Next.js storefront to fetch data from Contentful, or build a storefront that connects to both Medusa and Contentful.