687 lines
16 KiB
Plaintext
687 lines
16 KiB
Plaintext
import { Table } from "docs-ui"
|
||
|
||
export const metadata = {
|
||
title: `Contentful Plugin`,
|
||
}
|
||
|
||
# {metadata.title}
|
||
|
||
## Features
|
||
|
||
[Contentful](https://www.contentful.com/) 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 such as:
|
||
|
||
- Rich CMS details for product.
|
||
- Easy-to-use interface to manage content for static content and pages.
|
||
- Localization for product and storefront content.
|
||
- Two-way sync between Contentful and Medusa.
|
||
|
||
---
|
||
|
||
## Install the Contentful Plugin
|
||
|
||
<Note type="check">
|
||
|
||
- [Contentful account with a space](https://www.contentful.com/sign-up/).
|
||
- An Event Module installed in the Medusa application, such as the [Redis Event Module](../../../architectural-modules/event/redis/page.mdx).
|
||
|
||
</Note>
|
||
|
||
To install the Contentful plugin, run the following command in the directory of your Medusa application:
|
||
|
||
```bash npm2yarn
|
||
npm install medusa-plugin-contentful
|
||
```
|
||
|
||
Next, add the plugin into the `plugins` array in `medusa-config.js`:
|
||
|
||
export const highlights = [
|
||
["6", "space_id", "The Contentful space's ID."],
|
||
["7", "access_token", "The personal access token for content management."],
|
||
["8", "environment", "The Contentful environment."],
|
||
]
|
||
|
||
```js title="medusa-config.js" highlights={highlights}
|
||
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,
|
||
},
|
||
},
|
||
]
|
||
```
|
||
|
||
### Contentful Plugin Options
|
||
|
||
<Table>
|
||
<Table.Header>
|
||
<Table.Row>
|
||
<Table.HeaderCell>Option</Table.HeaderCell>
|
||
<Table.HeaderCell>Description</Table.HeaderCell>
|
||
<Table.HeaderCell>Required</Table.HeaderCell>
|
||
<Table.HeaderCell>Default</Table.HeaderCell>
|
||
</Table.Row>
|
||
</Table.Header>
|
||
<Table.Body>
|
||
<Table.Row>
|
||
<Table.Cell>
|
||
|
||
`space_id`
|
||
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
|
||
A string indicating the ID of your [Contentful space](https://www.contentful.com/help/find-space-id/).
|
||
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
|
||
Yes
|
||
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
|
||
\-
|
||
|
||
</Table.Cell>
|
||
</Table.Row>
|
||
<Table.Row>
|
||
<Table.Cell>
|
||
|
||
`access_token`
|
||
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
|
||
A string indicating [the personal access token for content management](https://www.contentful.com/help/personal-access-tokens/#how-to-get-a-personal-access-token-the-web-app).
|
||
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
|
||
Yes
|
||
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
|
||
\-
|
||
|
||
</Table.Cell>
|
||
</Table.Row>
|
||
<Table.Row>
|
||
<Table.Cell>
|
||
|
||
`environment`
|
||
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
|
||
A string indicating the [Contentful environment](https://www.contentful.com/developers/docs/concepts/multiple-environments/). Typically, its value should be `master`.
|
||
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
|
||
Yes
|
||
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
|
||
\-
|
||
|
||
</Table.Cell>
|
||
</Table.Row>
|
||
<Table.Row>
|
||
<Table.Cell>
|
||
|
||
`ignore_threshold`
|
||
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
|
||
The number of seconds to wait before re-syncing a specific record.
|
||
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
|
||
No
|
||
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
|
||
`2`
|
||
|
||
</Table.Cell>
|
||
</Table.Row>
|
||
<Table.Row>
|
||
<Table.Cell>
|
||
|
||
`custom_<TYPE>_fields`
|
||
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
|
||
An object that allows you to map fields in Medusa to [custom field names](#custom-field-mapping).
|
||
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
|
||
No
|
||
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
|
||
\-
|
||
|
||
</Table.Cell>
|
||
</Table.Row>
|
||
</Table.Body>
|
||
</Table>
|
||
|
||
### Environment Variables
|
||
|
||
Make sure to add the following environment variables.
|
||
|
||
```bash
|
||
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 use different names to define those fields in Contentful, 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 product’s `title` field, pass the following option to the plugin:
|
||
|
||
```js title="medusa-config.js" highlights={[["9"], ["10"], ["11"]]}
|
||
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.
|
||
|
||
<Details summaryContent="product">
|
||
|
||
- `title`
|
||
- `subtitle`
|
||
- `description`
|
||
- `variants`
|
||
- `options`
|
||
- `medusaId`
|
||
- `type`
|
||
- `collection`
|
||
- `tags`
|
||
- `handle`
|
||
|
||
</Details>
|
||
|
||
<Details summaryContent="variant">
|
||
|
||
- `title`
|
||
- `sku`
|
||
- `prices`
|
||
- `options`
|
||
- `medusaId`
|
||
|
||
</Details>
|
||
|
||
<Details summaryContent="region">
|
||
|
||
- `name`
|
||
- `countries`
|
||
- `paymentProviders`
|
||
- `fulfillmentProviders`
|
||
- `medusaId`
|
||
|
||
</Details>
|
||
|
||
<Details summaryContent="collection">
|
||
|
||
- `title`
|
||
- `medusaId`
|
||
|
||
</Details>
|
||
|
||
<Details summaryContent="type">
|
||
|
||
- `name`
|
||
- `medusaId`
|
||
|
||
</Details>
|
||
|
||
### 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 create a loader in the Medusa application that migrates these content models into Contentful.
|
||
|
||
This section includes migration scripts for Medusa’s data models that are relevant for Contentful.
|
||
|
||
Before creating the migration scripts, run the following command in the root of your Medusa backend to install Contentful’s migration SDK:
|
||
|
||
```bash npm2yarn
|
||
npm install --save-dev contentful-migration
|
||
```
|
||
|
||
<Details summaryContent="product Content Model">
|
||
|
||
Create the file `src/loaders/contentful-migrations/product.ts` with the following content:
|
||
|
||
```ts title="src/loaders/contentful-migrations/product.ts"
|
||
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")
|
||
}
|
||
```
|
||
|
||
</Details>
|
||
|
||
<Details summaryContent="productVariant Content Model">
|
||
|
||
Create the file `src/loaders/contentful-migrations/product-variant.ts` with the following content:
|
||
|
||
```ts title="src/loaders/contentful-migrations/product-variant.ts"
|
||
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")
|
||
}
|
||
```
|
||
|
||
</Details>
|
||
|
||
<Details summaryContent="collection Content Model">
|
||
|
||
Create the file `src/loaders/contentful-migrations/product-collection.ts` with the following content:
|
||
|
||
```ts title="src/loaders/contentful-migrations/product-collection.ts"
|
||
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")
|
||
}
|
||
```
|
||
|
||
</Details>
|
||
|
||
<Details summaryContent="productType Content Model">
|
||
|
||
Create the file `src/loaders/contentful-migrations/product-type.ts` with the following content:
|
||
|
||
```ts title="src/loaders/contentful-migrations/product-type.ts"
|
||
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")
|
||
}
|
||
```
|
||
|
||
</Details>
|
||
|
||
<Details summaryContent="region Content Model">
|
||
|
||
Create the file `src/loaders/contentful-migrations/region.ts` with the following content:
|
||
|
||
```ts title="src/loaders/contentful-migrations/region.ts"
|
||
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")
|
||
}
|
||
```
|
||
|
||
</Details>
|
||
|
||
Finally, create a loader at `src/loaders/index.ts` with the following content:
|
||
|
||
```ts title="src/loaders/index.ts"
|
||
import {
|
||
ConfigModule,
|
||
StoreService,
|
||
MedusaContainer,
|
||
} from "@medusajs/medusa"
|
||
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: MedusaContainer,
|
||
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 store’s `metadata` attribute to ensure these migrations only run once.
|
||
|
||
### Setup Webhooks
|
||
|
||
As mentioned in the introduction, this plugin supports two-way sync. A subscriber in the plugin listens to changes in the data, such as adding a new product, and syncs the data with Contentful.
|
||
|
||
To update the Medusa application when changes occur in Contentful, you must configure webhooks settings in Contentful.
|
||
|
||
<Note>
|
||
|
||
For webhooks to work, your Medusa application must be deployed and accessible publicly.
|
||
|
||
</Note>
|
||
|
||
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 `<MEDUSA_URL>/hooks/contentful` where `<MEDUSA_URL>` is the URL of your deployed Medusa application.
|
||
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 application and test the plugin:
|
||
|
||
```bash npm2yarn
|
||
npm run dev
|
||
```
|
||
|
||
If you created migration scripts, they’ll run when the Medusa application starts and migrate your content models to Contentful. You can go to your space’s dashboard to confirm they’ve been created.
|
||
|
||
After that, try the sync functionality by creating or updating products in the Medusa application. If you’ve also setup webhooks, you can test out the sync from Contentful to Medusa.
|