feat: ecommerce plugin and template (#8297)

This PR adds an ecommerce plugin package with both a Payload plugin and
React UI utilities for the frontend. It also adds a new ecommerce
template and new ecommerce test suite.

It also makes a change to the `cpa` package to accept a `--version` flag
to install a specific version of Payload defaulting to the latest.
This commit is contained in:
Paul
2025-09-30 01:05:16 +01:00
committed by GitHub
parent 92a5f075b6
commit ef4874b9a0
372 changed files with 30874 additions and 331 deletions

2
.gitignore vendored
View File

@@ -322,6 +322,8 @@ test/admin-root/app/(payload)/admin/importMap.js
/test/admin-root/app/(payload)/admin/importMap.js /test/admin-root/app/(payload)/admin/importMap.js
test/app/(payload)/admin/importMap.js test/app/(payload)/admin/importMap.js
/test/app/(payload)/admin/importMap.js /test/app/(payload)/admin/importMap.js
test/plugin-ecommerce/app/(payload)/admin/importMap.js
/test/plugin-ecommerce/app/(payload)/admin/importMap.js
test/pnpm-lock.yaml test/pnpm-lock.yaml
test/databaseAdapter.js test/databaseAdapter.js
/filename-compound-index /filename-compound-index

View File

@@ -61,9 +61,15 @@ When validating Payload-generated JWT tokens in external services, use the proce
```ts ```ts
import crypto from 'node:crypto' import crypto from 'node:crypto'
const secret = crypto.createHash('sha256').update(process.env.PAYLOAD_SECRET).digest('hex').slice(0, 32) const secret = crypto
.createHash('sha256')
.update(process.env.PAYLOAD_SECRET)
.digest('hex')
.slice(0, 32)
``` ```
<Banner type="info"> <Banner type="info">
**Note:** Payload processes your secret using SHA-256 hash and takes the first 32 characters. This processed value is what's used for JWT operations, not your original secret. **Note:** Payload processes your secret using SHA-256 hash and takes the first
32 characters. This processed value is what's used for JWT operations, not
your original secret.
</Banner> </Banner>

446
docs/ecommerce/advanced.mdx Normal file
View File

@@ -0,0 +1,446 @@
---
title: Advanced uses and examples
label: Advanced
order: 60
desc: Add ecommerce functionality to your Payload CMS application with this plugin.
keywords: plugins, ecommerce, stripe, plugin, payload, cms, shop, payments
---
The plugin also exposes its internal utilities so that you can use only the parts that you need without using the entire plugin. This is useful if you want to build your own ecommerce solution on top of Payload.
## Using only the collections
You can import the collections directly from the plugin and add them to your Payload configuration. This way, you can use the collections without using the entire plugin:
| Name | Collection | Description |
| -------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `createAddressesCollection` | `addresses` | Used for customer addresses (like shipping and billing). [More](#createAddressesCollection) |
| `createCartsCollection` | `carts` | Carts can be used by customers, guests and once purchased are kept for records and analytics. [More](#createCartsCollection) |
| `createOrdersCollection` | `orders` | Orders are used to store customer-side information and are related to at least one transaction. [More](#createOrdersCollection) |
| `createTransactionsCollection` | `transactions` | Handles payment information accessable by admins only, related to Orders. [More](#createTransactionsCollection) |
| `createProductsCollection` | `products` | All the product information lives here, contains prices, relations to Variant Types and joins to Variants. [More](#createProductsCollection) |
| `createVariantsCollection` | `variants` | Product variants, unique purchasable items that are linked to a product and Variant Options. [More](#createVariantsCollection) |
| `createVariantTypesCollection` | `variantTypes` | A taxonomy used by Products to relate Variant Options together. An example of a Variant Type is "size". [More](#createVariantTypesCollection) |
| `createVariantOptionsCollection` | `variantOptions` | Related to a Variant Type to handle a unique property of it. An example of a Variant Option is "small". [More](#createVariantOptionsCollection) |
### createAddressesCollection
Use this to create the `addresses` collection. This collection is used to store customer addresses. It takes the following properties:
| Property | Type | Description |
| -------------------- | --------------- | --------------------------------------------------------------------- |
| `access` | `object` | Access control for the collection. |
| `addressFields` | `Field[]` | Custom fields to add to the address. |
| `customersSlug` | `string` | (Optional) Slug of the customers collection. Defaults to `customers`. |
| `supportedCountries` | `CountryType[]` | (Optional) List of supported countries. Defaults to all countries. |
The access object can contain the following properties:
| Property | Type | Description |
| ------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `adminOrCustomerOwner` | `Access` | Access control to check if the user has `admin` permissions or is the owner of the document via the `customer` field. Used to limit read, update or delete to only the customers that own this address. |
| `authenticatedOnly` | `Access` | Access control to check if the user is authenticated. Use on the `create` access to allow any customer to create a new address. |
| `customerOnlyFieldAccess` | `FieldAccess` | Field level access control to check if the user has `customer` permissions. |
See the [access control section](./plugin#access) for more details on each of these functions.
Example usage:
```ts
import { createAddressesCollection } from 'payload-plugin-ecommerce'
const Addresses = createAddressesCollection({
access: {
adminOrCustomerOwner,
authenticatedOnly,
customerOnlyFieldAccess,
},
addressFields: [
{
name: 'company',
type: 'text',
label: 'Company',
},
],
})
```
### createCartsCollection
Use this to create the `carts` collection to store customer carts. It takes the following properties:
| Property | Type | Description |
| ------------------ | ------------------ | ----------------------------------------------------------------------- |
| `access` | `object` | Access control for the collection. |
| `customersSlug` | `string` | (Optional) Slug of the customers collection. Defaults to `customers`. |
| `productsSlug` | `string` | (Optional) Slug of the products collection. Defaults to `products`. |
| `variantsSlug` | `string` | (Optional) Slug of the variants collection. Defaults to `variants`. |
| `enableVariants` | `boolean` | (Optional) Whether to enable variants in the cart. Defaults to `true`. |
| `currenciesConfig` | `CurrenciesConfig` | (Optional) Currencies configuration to enable a subtotal to be tracked. |
The access object can contain the following properties:
| Property | Type | Description |
| ---------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `adminOrCustomerOwner` | `Access` | Access control to check if the user has `admin` permissions or is the owner of the document via the `customer` field. Used to limit read, update or delete to only the customers that own this cart. |
| `publicAccess` | `Access` | Allow anyone to create a new cart, useful for guests. |
See the [access control section](./plugin#access) for more details on each of these functions.
Example usage:
```ts
import { createCartsCollection } from 'payload-plugin-ecommerce'
const Carts = createCartsCollection({
access: {
adminOrCustomerOwner,
publicAccess,
},
enableVariants: true,
currenciesConfig: {
defaultCurrency: 'usd',
currencies: [
{
code: 'usd',
symbol: '$',
},
{
code: 'eur',
symbol: '€',
},
],
},
})
```
### createOrdersCollection
Use this to create the `orders` collection to store customer orders. It takes the following properties:
| Property | Type | Description |
| ------------------ | ------------------ | --------------------------------------------------------------------------- |
| `access` | `object` | Access control for the collection. |
| `customersSlug` | `string` | (Optional) Slug of the customers collection. Defaults to `customers`. |
| `transactionsSlug` | `string` | (Optional) Slug of the transactions collection. Defaults to `transactions`. |
| `productsSlug` | `string` | (Optional) Slug of the products collection. Defaults to `products`. |
| `variantsSlug` | `string` | (Optional) Slug of the variants collection. Defaults to `variants`. |
| `enableVariants` | `boolean` | (Optional) Whether to enable variants in the order. Defaults to `true`. |
| `currenciesConfig` | `CurrenciesConfig` | (Optional) Currencies configuration to enable the amount to be tracked. |
| `addressFields` | `Field[]` | (Optional) The fields to be used for the shipping address. |
The access object can contain the following properties:
| Property | Type | Description |
| ---------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `adminOrCustomerOwner` | `Access` | Access control to check if the user has `admin` permissions or is the owner of the document via the `customer` field. Used to limit read to only the customers that own this order. |
| `adminOnly` | `Access` | Access control to check if the user has `admin` permissions. Used to limit create, update and delete access to only admins. |
| `adminOnlyFieldAccess` | `FieldAccess` | Field level access control to check if the user has `admin` permissions. Limits the transaction ID field to admins only. |
See the [access control section](./plugin#access) for more details on each of these functions.
Example usage:
```ts
import { createOrdersCollection } from 'payload-plugin-ecommerce'
const Orders = createOrdersCollection({
access: {
adminOrCustomerOwner,
adminOnly,
adminOnlyFieldAccess,
},
enableVariants: true,
currenciesConfig: {
defaultCurrency: 'usd',
currencies: [
{
code: 'usd',
symbol: '$',
},
{
code: 'eur',
symbol: '€',
},
],
},
addressFields: [
{
name: 'deliveryInstructions',
type: 'text',
label: 'Delivery Instructions',
},
],
})
```
### createTransactionsCollection
Use this to create the `transactions` collection to store payment transactions. It takes the following properties:
| Property | Type | Description |
| ------------------ | ------------------ | ----------------------------------------------------------------------------- |
| `access` | `object` | Access control for the collection. |
| `customersSlug` | `string` | (Optional) Slug of the customers collection. Defaults to `customers`. |
| `cartsSlug` | `string` | (Optional) Slug of the carts collection. Defaults to `carts`. |
| `ordersSlug` | `string` | (Optional) Slug of the orders collection. Defaults to `orders`. |
| `productsSlug` | `string` | (Optional) Slug of the products collection. Defaults to `products`. |
| `variantsSlug` | `string` | (Optional) Slug of the variants collection. Defaults to `variants`. |
| `enableVariants` | `boolean` | (Optional) Whether to enable variants in the transaction. Defaults to `true`. |
| `currenciesConfig` | `CurrenciesConfig` | (Optional) Currencies configuration to enable the amount to be tracked. |
| `addressFields` | `Field[]` | (Optional) The fields to be used for the billing address. |
| `paymentMethods` | `PaymentAdapter[]` | (Optional) The payment methods to be used for the transaction. |
The access object can contain the following properties:
| Property | Type | Description |
| ----------- | -------- | ----------------------------------------------------------------------------------------------------- |
| `adminOnly` | `Access` | Access control to check if the user has `admin` permissions. Used to limit all access to only admins. |
See the [access control section](./plugin#access) for more details on each of these functions.
Example usage:
```ts
import { createTransactionsCollection } from 'payload-plugin-ecommerce'
const Transactions = createTransactionsCollection({
access: {
adminOnly,
},
enableVariants: true,
currenciesConfig: {
defaultCurrency: 'usd',
currencies: [
{
code: 'usd',
symbol: '$',
},
{
code: 'eur',
symbol: '€',
},
],
},
addressFields: [
{
name: 'billingInstructions',
type: 'text',
label: 'Billing Instructions',
},
],
paymentMethods: [
// Add your payment adapters here
],
})
```
### createProductsCollection
Use this to create the `products` collection to store products. It takes the following properties:
| Property | Type | Description |
| ------------------ | --------------------------- | -------------------------------------------------------------------------------- |
| `access` | `object` | Access control for the collection. |
| `variantsSlug` | `string` | (Optional) Slug of the variants collection. Defaults to `variants`. |
| `variantTypesSlug` | `string` | (Optional) Slug of the variant types collection. Defaults to `variantTypes`. |
| `enableVariants` | `boolean` | (Optional) Whether to enable variants on products. Defaults to `true`. |
| `currenciesConfig` | `CurrenciesConfig` | (Optional) Currencies configuration to enable price fields. |
| `inventory` | `boolean` `InventoryConfig` | (Optional) Inventory configuration to enable stock tracking. Defaults to `true`. |
The access object can contain the following properties:
| Property | Type | Description |
| ------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `adminOnly` | `Access` | Access control to check if the user has `admin` permissions. Used to limit create, update or delete to only admins. |
| `adminOrPublishedStatus` | `Access` | Access control to check if the user has `admin` permissions or if the product has a `published` status. Used to limit read access to published products for non-admins. |
See the [access control section](./plugin#access) for more details on each of these functions.
Example usage:
```ts
import { createProductsCollection } from 'payload-plugin-ecommerce'
const Products = createProductsCollection({
access: {
adminOnly,
adminOrPublishedStatus,
},
enableVariants: true,
currenciesConfig: {
defaultCurrency: 'usd',
currencies: [
{
code: 'usd',
symbol: '$',
},
{
code: 'eur',
symbol: '€',
},
],
},
inventory: {
enabled: true,
trackByVariant: true,
lowStockThreshold: 5,
},
})
```
### createVariantsCollection
Use this to create the `variants` collection to store product variants. It takes the following properties:
| Property | Type | Description |
| -------------------- | --------------------------- | -------------------------------------------------------------------------------- |
| `access` | `object` | Access control for the collection. |
| `productsSlug` | `string` | (Optional) Slug of the products collection. Defaults to `products`. |
| `variantOptionsSlug` | `string` | (Optional) Slug of the variant options collection. Defaults to `variantOptions`. |
| `currenciesConfig` | `CurrenciesConfig` | (Optional) Currencies configuration to enable price fields. |
| `inventory` | `boolean` `InventoryConfig` | (Optional) Inventory configuration to enable stock tracking. Defaults to `true`. |
The access object can contain the following properties:
| Property | Type | Description |
| ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `adminOnly` | `Access` | Access control to check if the user has `admin` permissions. Used to limit all access to only admins. |
| `adminOrPublishedStatus` | `Access` | Access control to check if the user has `admin` permissions or if the related product has a `published` status. Used to limit read access to variants of published products for non-admins. |
See the [access control section](./plugin#access) for more details on each of these functions.
Example usage:
```ts
import { createVariantsCollection } from 'payload-plugin-ecommerce'
const Variants = createVariantsCollection({
access: {
adminOnly,
adminOrPublishedStatus,
},
currenciesConfig: {
defaultCurrency: 'usd',
currencies: [
{
code: 'usd',
symbol: '$',
},
{
code: 'eur',
symbol: '€',
},
],
},
inventory: {
enabled: true,
lowStockThreshold: 5,
},
})
```
### createVariantTypesCollection
Use this to create the `variantTypes` collection to store variant types. It takes the following properties:
| Property | Type | Description |
| -------------------- | -------- | -------------------------------------------------------------------------------- |
| `access` | `object` | Access control for the collection. |
| `variantOptionsSlug` | `string` | (Optional) Slug of the variant options collection. Defaults to `variantOptions`. |
The access object can contain the following properties:
| Property | Type | Description |
| -------------- | -------- | ----------------------------------------------------------------------------------------------------- |
| `adminOnly` | `Access` | Access control to check if the user has `admin` permissions. Used to limit all access to only admins. |
| `publicAccess` | `Access` | Allow anyone to read variant types. |
See the [access control section](./plugin#access) for more details on each of these functions.
Example usage:
```ts
import { createVariantTypesCollection } from 'payload-plugin-ecommerce'
const VariantTypes = createVariantTypesCollection({
access: {
adminOnly,
publicAccess,
},
})
```
### createVariantOptionsCollection
Use this to create the `variantOptions` collection to store variant options. It takes the following properties:
| Property | Type | Description |
| ------------------ | -------- | ---------------------------------------------------------------------------- |
| `access` | `object` | Access control for the collection. |
| `variantTypesSlug` | `string` | (Optional) Slug of the variant types collection. Defaults to `variantTypes`. |
The access object can contain the following properties:
| Property | Type | Description |
| -------------- | -------- | ----------------------------------------------------------------------------------------------------- |
| `adminOnly` | `Access` | Access control to check if the user has `admin` permissions. Used to limit all access to only admins. |
| `publicAccess` | `Access` | Allow anyone to read variant options. |
See the [access control section](./plugin#access) for more details on each of these functions.
Example usage:
```ts
import { createVariantOptionsCollection } from 'payload-plugin-ecommerce'
const VariantOptions = createVariantOptionsCollection({
access: {
adminOnly,
publicAccess,
},
})
```
## Typescript
There are several common types that you'll come across when working with this package. These are export from the package as well and are used across individual utilities as well.
### CurrenciesConfig
Defines the supported currencies in Payload and the frontend. It has the following properties:
| Property | Type | Description |
| ----------------- | ---------------- | --------------------------------------------------------------------------------- |
| `defaultCurrency` | `string` | The default currency code. Must match one of the codes in the `currencies` array. |
| `currencies` | `CurrencyType[]` | An array of supported currencies. Each currency must have a unique code. |
### Currency
Defines a currency to be used in the application. It has the following properties:
| Property | Type | Description |
| ---------- | -------- | ------------------------------------------------ |
| `code` | `string` | The ISO 4217 currency code. Example `'usd'`. |
| `symbol` | `string` | The symbol of the currency. Example `'$'` |
| `label` | `string` | The name of the currency. Example `'USD'` |
| `decimals` | `number` | The number of decimal places to use. Example `2` |
The decimals is very important to provide as we store all prices as integers to avoid floating point issues. For example, if you're using USD, you would store a price of $10.00 as `1000` (10 \* 10^2), so when formatting the price for display we need to know how many decimal places the currency supports.
### CountryType
Used to define a country in address fields and supported countries lists. It has the following properties:
| Property | Type | Description |
| -------- | -------- | ---------------------------- |
| `value` | `string` | The ISO 3166-1 alpha-2 code. |
| `label` | `string` | The name of the country. |
### InventoryConfig
It's used to customise the inventory tracking settings on products and variants. It has the following properties:
| Property | Type | Description |
| ----------- | -------- | ------------------------------------------------------------------------------------ |
| `fieldName` | `string` | (Optional) The name of the field to use for tracking stock. Defaults to `inventory`. |

270
docs/ecommerce/frontend.mdx Normal file
View File

@@ -0,0 +1,270 @@
---
title: Ecommerce Frontend
label: Frontend
order: 30
desc: Add ecommerce functionality to your Payload CMS application with this plugin.
keywords: plugins, ecommerce, stripe, plugin, payload, cms, shop, payments
---
The package provides a set of React utilities to help you manage your ecommerce frontend. These include context providers, hooks, and components to handle carts, products, and payments.
The following hooks and components are available:
| Hook / Component | Description |
| ------------------- | ------------------------------------------------------------------------------ |
| `EcommerceProvider` | A context provider to wrap your application and provide the ecommerce context. |
| `useCart` | A hook to manage the cart state and actions. |
| `useAddresses` | A hook to fetch and manage products. |
| `usePayments` | A hook to manage the checkout process. |
| `useCurrency` | A hook to format prices based on the selected currency. |
| `useEcommerce` | A hook that encompasses all of the above in one. |
### EcommerceProvider
The `EcommerceProvider` component is used to wrap your application and provide the ecommerce context. It takes the following props:
| Prop | Type | Description |
| ------------------ | ------------------ | ----------------------------------------------------------------------------------------------------------- |
| `addressesSlug` | `string` | The slug of the addresses collection. Defaults to `addresses`. |
| `api` | `object` | API configuration for the internal fetches of the provider. [More](#api) |
| `cartsSlug` | `string` | The slug of the carts collection. Defaults to `carts`. |
| `children` | `ReactNode` | The child components that will have access to the ecommerce context. |
| `currenciesConfig` | `CurrenciesConfig` | Configuration for supported currencies. See [Currencies](./plugin#currencies). |
| `customersSlug` | `string` | The slug of the customers collection. Defaults to `users`. |
| `debug` | `boolean` | Enable or disable debug mode. This will send more information to the console. |
| `enableVariants` | `boolean` | Enable or disable product variants support. Defaults to `true`. |
| `paymentMethods` | `PaymentMethod[]` | An array of payment method adapters for the client side. See [Payment adapters](./plugin#payment-adapters). |
| `syncLocalStorage` | `boolean` `object` | Whether to sync the cart ID to local storage. Defaults to `true`. Takes an object for configuration |
Example usage:
```tsx
import { EcommerceProvider } from '@payloadcms/plugin-ecommerce/react'
// Import any payment adapters you want to use on the client side
import { stripeAdapterClient } from '@payloadcms/plugin-ecommerce/payments/stripe'
import { USD, EUR } from '@payloadcms/plugin-ecommerce/currencies'
export const Providers = () => (
<EcommerceProvider
enableVariants={true}
currenciesConfig={{
supportedCurrencies: [USD, EUR],
defaultCurrency: 'USD',
}}
>
{children}
</EcommerceProvider>
)
```
#### api
The `api` prop is used to configure the API settings for the internal fetches of the provider. It takes an object with the following properties:
| Property | Type | Description |
| ----------------- | -------- | ----------------------------------------------------------------- |
| `apiRoute` | `string` | The base route for accessing the Payload API. Defaults to `/api`. |
| `serverURL` | `string` | The full URL of your Payload server. |
| `cartsFetchQuery` | `object` | Additional query parameters to include when fetching the cart. |
#### cartsFetchQuery
The `cartsFetchQuery` property allows you to specify additional query parameters to include when fetching the cart. This can be useful for including related data or customizing the response. This accepts:
| Property | Type | Description |
| ---------- | -------------- | --------------------------------------------------------------- |
| `depth` | `string` | Defaults to 0. [See Depth](../queries/depth) |
| `select` | `SelectType` | Select parameters. [See Select](../queries/select) |
| `populate` | `PopulateType` | Populate parameters. [See Populate](../queries/select#populate) |
Example usage:
```tsx
<EcommerceProvider
api={{
cartsFetchQuery: {
depth: 2, // Include related data up to 2 levels deep
},
}}
>
{children}
</EcommerceProvider>
```
#### syncLocalStorage
The `syncLocalStorage` prop is used to enable or disable syncing the cart ID to local storage. This allows the cart to persist across page reloads and sessions. It defaults to `true`.
You can also provide an object with the following properties for more configuration:
| Property | Type | Description |
| -------- | -------- | ---------------------------------------------------------------------------- |
| `key` | `string` | The key to use for storing the cart ID in local storage. Defaults to `cart`. |
### useCart
The `useCart` hook is used to manage the cart state and actions. It provides methods to add, remove, and update items in the cart, as well as to fetch the current cart state. It has the following properties:
| Property | Type | Description |
| --------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `addItem` | `(item: CartItemInput, quantity?: number) => void` | Method to add an item to the cart, optionally accepts a quantity to add multiple at once. |
| `cart` | `Cart` `null` | The current cart state. Null or undefined if it doesn't exist. |
| `clearCart` | `() => void` | Method to clear the cart. |
| `decrementItem` | `(item: IDType) => void` | Method to decrement the quantity of an item. Will remove it entirely if it reaches 0. |
| `incrementItem` | `(item: IDType) => void` | Method to increment the quantity of an item. |
| `removeItem` | `(item: IDType) => void` | Method to remove an item from the cart. |
Example usage:
```tsx
import { useCart } from '@payloadcms/plugin-ecommerce/react'
const CartComponent = () => {
const { addItem, cart, clearCart, decrementItem, incrementItem, removeItem } =
useCart()
// Your component logic here
}
```
### useAddresses
The `useAddresses` hook is used to fetch and manage addresses. It provides methods to create, update, and delete addresses, as well as to fetch the list of addresses. It has the following properties:
| Property | Type | Description |
| --------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------- |
| `addresses` | `Address[]` | The list of addresses, if any are available for the current user. |
| `createAddress` | `(data: Address) => Promise<Address>` | Method to create a new address. |
| `updateAddress` | `(addressID: IDType, data: Partial<Address>) => Promise<Address>` | Method to update an existing address by ID. |
Example usage:
```tsx
import { useAddresses } from '@payloadcms/plugin-ecommerce/react'
const AddressesComponent = () => {
const { addresses, createAddress, updateAddress } = useAddresses()
// Your component logic here
}
```
### usePayments
The `usePayments` hook is used to manage the checkout process. It provides methods to initiate payments, confirm orders, and handle payment status. It has the following properties:
| Property | Type | Description |
| ----------------------- | -------------------------- | ------------------------------------------------------------------- |
| `confirmOrder` | `(args) => Promise<Order>` | Method to confirm an order by ID. [More](#confirmOrder) |
| `initiatePayment` | `(args) => Promise<void>` | Method to initiate a payment for an order. [More](#initiatePayment) |
| `paymentMethods` | `PaymentMethod[]` | The list of available payment methods. |
| `selectedPaymentMethod` | `PaymentMethod` | The currently selected payment method, if any. |
Example usage:
```tsx
import { usePayments } from '@payloadcms/plugin-ecommerce/react'
const CheckoutComponent = () => {
const {
confirmOrder,
initiatePayment,
paymentMethods,
selectedPaymentMethod,
} = usePayments()
// Your component logic here
}
```
#### confirmOrder
Use this method to confirm an order by its ID. It requires the payment method ID and will return the order ID.
```ts
try {
const data = await confirmOrder('stripe', {
additionalData: {
paymentIntentID: paymentIntent.id,
customerEmail,
},
})
// Return type will contain `orderID`
// use data to redirect to your order page
} catch (error) {
// handle error
}
```
If the payment gateway requires additional confirmations offsite then you will need another landing page to handle that. For example with Stripe you may need to use a callback URL, just make sure the relevant information is routed back.
<Banner type="info">
This will mark the transaction as complete in the backend and create the order
for the user.
</Banner>
#### initiatePayment
Use this method to initiate a payment for an order. It requires the cart ID and the payment method ID. Depending on the payment method, additional data may be required. Depending on the payment method used you may need to provide additional data, for example with Stripe:
```ts
try {
const data = await initiatePayment('stripe', {
additionalData: {
customerEmail,
billingAddress,
shippingAddress,
},
})
} catch (error) {
// handle error
}
```
This function will hit the Payload API endpoint for `/stripe/initiate` and return the payment data required to complete the payment on the client side, which by default will include a `client_secret` to complete the payment with Stripe.js. The next step is to call the `confirmOrder` once payment is confirmed on the client side by Stripe.
<Banner type="info">
At this step the cart is verified and a transaction is created in the backend
with the address details provided. No order is created yet until you call
`confirmOrder`, which should be done after payment is confirmed on the client
side or via webhooks if you opt for that approach instead.
</Banner>
### useCurrency
The `useCurrency` hook is used to format prices based on the selected currency. It provides methods to format prices and to get the current currency. It has the following properties:
| Property | Type | Description |
| ------------------ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `currenciesConfig` | `CurrenciesConfig` | The configuration for supported currencies. Directly matching the config provided to the Context Provider. [More](#ecommerceprovider) |
| `currency` | `Currency` | The currently selected currency. |
| `formatPrice` | `(amount: number) => string` | Method to format a price based on the selected currency. |
| `setCurrency` | `(currencyCode: string) => void` | Method to set the current currency by code. It will update all price formats when used in conjunction with the `formatPrice` utility. |
`formatPrice` in particular is very helpful as all prices are stored as integers to avoid any potential issues with decimal calculations, therefore on the frontend you can use this utility to format your price accounting for the currency and decimals. Example usage:
```tsx
import { useCurrency } from '@payloadcms/plugin-ecommerce/react'
const PriceComponent = ({ amount }) => {
const { currenciesConfig, currency, setCurrency } = useCurrency()
return <div>{formatPrice(amount)}</div>
}
```
### useEcommerce
The `useEcommerce` hook encompasses all of the above hooks in one. It provides access to the cart, addresses, and payments hooks.
Example usage:
```tsx
import { useEcommerce } from '@payloadcms/plugin-ecommerce/react'
const EcommerceComponent = () => {
const { cart, addresses, selectedPaymentMethod } = useEcommerce()
// Your component logic here
}
```

138
docs/ecommerce/overview.mdx Normal file
View File

@@ -0,0 +1,138 @@
---
title: Ecommerce Overview
label: Overview
order: 10
desc: Add ecommerce functionality to your Payload CMS application with this plugin.
keywords: plugins, ecommerce, stripe, plugin, payload, cms, shop, payments
---
![https://www.npmjs.com/package/@payloadcms/plugin-ecommerce](https://img.shields.io/npm/v/@payloadcms/plugin-ecommerce)
<Banner type="warning">
This plugin is currently in Beta and may have breaking changes in future
releases.
</Banner>
Payload provides an Ecommerce Plugin that allows you to add ecommerce functionality to your app. It comes a set of utilities and collections to manage products, orders, and payments. It also integrates with popular payment gateways like Stripe to handle transactions.
<Banner type="info">
This plugin is completely open-source and the [source code can be found
here](https://github.com/payloadcms/payload/tree/main/packages/plugin-ecommerce).
If you need help, check out our [Community
Help](https://payloadcms.com/community-help). If you think you've found a bug,
please [open a new
issue](https://github.com/payloadcms/payload/issues/new?assignees=&labels=plugin%3A%redirects&template=bug_report.md&title=plugin-ecommerce%3A)
with as much detail as possible.
</Banner>
## Core features
The plugin ships with a wide range of features to help you get started with ecommerce:
- Products with Variants are supported by default
- Carts are tracked in Payload
- Orders and Transactions
- Addresses linked to your Customers
- Payments adapter pattern to create your own integrations (Stripe currently supported)
- Multiple currencies are supported
- React UI utilities to help you manage your frontend logic
_Currently_ the plugin does not handle shipping, taxes or subscriptions natively, but you can implement these features yourself using the provided collections and hooks.
## Installation
Install the plugin using any JavaScript package manager like [pnpm](https://pnpm.io), [npm](https://npmjs.com), or [Yarn](https://yarnpkg.com):
```bash
pnpm add @payloadcms/plugin-ecommerce
```
## Basic Usage
In the `plugins` array of your [Payload Config](https://payloadcms.com/docs/configuration/overview), call the plugin with:
```ts
import { buildConfig } from 'payload'
import { ecommercePlugin } from '@payloadcms/plugin-ecommerce'
const config = buildConfig({
collections: [
{
slug: 'pages',
fields: [],
},
],
plugins: [
ecommercePlugin({
// You must add your access control functions here
access: {
adminOnly,
adminOnlyFieldAccess,
adminOrCustomerOwner,
adminOrPublishedStatus,
customerOnlyFieldAccess,
},
customers: { slug: 'users' },
}),
],
})
export default config
```
## Concepts
It's important to understand overall how the plugin works and the relationships between the different collections.
**Customers**
Can be any collection of users in your application. You can then limit access control only to customers depending on individual fields such
as roles on the customer collection or by collection slug if you've opted to keep them separate. Customers are linked to Carts and Orders.
**Products and Variants**
Products are the items you are selling and they will contain a price and optionally variants via a join field as well as allowed Variant Types.
Each Variant Type can contain a set of Variant Options. For example, a T-Shirt product can have a Variant Type of Size with options Small, Medium, and Large and each Variant can therefore have those options assigned to it.
**Carts**
Carts are linked to Customers or they're left entirely public for guests users and can contain multiple Products and Variants. Carts are stored in the database and can be retrieved at any time. Carts are automatically created for Customers when they add a product to their cart for the first time.
**Transactions and Orders**
Transactions are created when a payment is initiated. They contain the payment status and are linked to a Cart and Customer. Orders are created when a Transaction is successful and contain the final details of the purchase including the items, total, and customer information.
**Addresses**
Addresses are linked to Customers and can be used for billing and shipping information. They can be reused across multiple Orders.
**Payments**
The plugin uses an adapter pattern to allow for different payment gateways. The default adapter is for Stripe, but you can create your own by implementing the `PaymentAdapter` interface.
**Currencies**
The plugin supports using multiple currencies at the configuration level. Each currency will create a separate price field on the Product and Variants collections.
The package can also be used piece-meal if you only want to re-use certain parts of it, such as just the creation of Products and Variants. See [Advanced uses and examples](./advanced) for more details.
## TypeScript
The plugin will inherit the types from your generated Payload types where possible. We also export the following types:
- `Cart` - The cart type as stored in the React state and local storage and on the client side.
- `CollectionOverride` - Type for overriding collections.
- `CurrenciesConfig` - Type for the currencies configuration.
- `EcommercePluginConfig` - The configuration object for the ecommerce plugin.
- `FieldsOverride` - Type for overriding fields in collections.
All types can be directly imported:
```ts
import { EcommercePluginConfig } from '@payloadcms/plugin-ecommerce/types'
```
## Template
The [Templates Directory](https://github.com/payloadcms/payload/tree/main/templates) also contains an official [E-commerce Template](https://github.com/payloadcms/payload/tree/main/templates/ecommerce), which uses this plugin.

422
docs/ecommerce/payments.mdx Normal file
View File

@@ -0,0 +1,422 @@
---
title: Payment Adapters
label: Payment Adapters
order: 40
desc: Add ecommerce functionality to your Payload CMS application with this plugin.
keywords: plugins, ecommerce, stripe, plugin, payload, cms, shop, payments
---
A deeper look into the payment adapter pattern used by the Ecommerce Plugin, and how to create your own.
The current list of supported payment adapters are:
- [Stripe](#stripe)
## REST API
The plugin will create REST API endpoints for each payment adapter you add to your configuration. The endpoints will be available at `/api/payments/{provider_name}/{action}` where `provider_name` is the name of the payment adapter and `action` is one of the following:
| Action | Method | Description |
| --------------- | ------ | --------------------------------------------------------------------------------------------------- |
| `initiate` | POST | Initiate a payment for an order. See [initiatePayment](#initiatePayment) for more details. |
| `confirm-order` | POST | Confirm an order after a payment has been made. See [confirmOrder](#confirmOrder) for more details. |
## Stripe
Out of the box we integrate with Stripe to handle one-off purchases. To use Stripe, you will need to install the Stripe package:
```bash
pnpm add stripe
```
We recommend at least `18.5.0` to ensure compatibility with the plugin.
Then, in your `plugins` array of your [Payload Config](https://payloadcms.com/docs/configuration/overview), call the plugin with:
```ts
import { ecommercePlugin } from '@payloadcms/plugin-ecommerce'
import { stripeAdapter } from '@payloadcms/plugin-ecommerce/payments/stripe'
import { buildConfig } from 'payload'
export default buildConfig({
// Payload config...
plugins: [
ecommercePlugin({
// rest of config...
payments: {
paymentMethods: [
stripeAdapter({
secretKey: process.env.STRIPE_SECRET_KEY,
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
// Optional - only required if you want to use webhooks
webhookSecret: process.env.STRIPE_WEBHOOKS_SIGNING_SECRET,
}),
],
},
}),
],
})
```
### Configuration
The Stripe payment adapter takes the following configuration options:
| Option | Type | Description |
| -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| secretKey | `string` | Your Stripe Secret Key, found in the [Stripe Dashboard](https://dashboard.stripe.com/apikeys). |
| publishableKey | `string` | Your Stripe Publishable Key, found in the [Stripe Dashboard](https://dashboard.stripe.com/apikeys). |
| webhookSecret | `string` | (Optional) Your Stripe Webhooks Signing Secret, found in the [Stripe Dashboard](https://dashboard.stripe.com/webhooks). Required if you want to use webhooks. |
| appInfo | `object` | (Optional) An object containing `name` and `version` properties to identify your application to Stripe. |
| webhooks | `object` | (Optional) An object where the keys are Stripe event types and the values are functions that will be called when that event is received. See [Webhooks](#stripe-webhooks) for more details. |
| groupOverrides | `object` | (Optional) An object to override the default fields of the payment group. See [Payment Fields](./advanced#payment-fields) for more details. |
### Stripe Webhooks
You can also add your own webhooks to handle [events from Stripe](https://docs.stripe.com/api/events). This is optional and the plugin internally does not use webhooks for any core functionality. It receives the following arguments:
| Argument | Type | Description |
| -------- | ---------------- | ------------------------------- |
| event | `Stripe.Event` | The Stripe event object |
| req | `PayloadRequest` | The Payload request object |
| stripe | `Stripe` | The initialized Stripe instance |
You can add a webhook like so:
```ts
import { ecommercePlugin } from '@payloadcms/plugin-ecommerce'
import { stripeAdapter } from '@payloadcms/plugin-ecommerce/payments/stripe'
import { buildConfig } from 'payload'
export default buildConfig({
// Payload config...
plugins: [
ecommercePlugin({
// rest of config...
payments: {
paymentMethods: [
stripeAdapter({
secretKey: process.env.STRIPE_SECRET_KEY,
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
// Required
webhookSecret: process.env.STRIPE_WEBHOOKS_SIGNING_SECRET,
webhooks: {
'payment_intent.succeeded': ({ event, req }) => {
console.log({ event, data: event.data.object })
req.payload.logger.info('Payment succeeded')
},
},
}),
],
},
}),
],
})
```
To use webhooks you also need to have them configured in your Stripe Dashboard.
You can use the [Stripe CLI](https://stripe.com/docs/stripe-cli) to forward webhooks to your local development environment.
### Frontend usage
The most straightforward way to use Stripe on the frontend is with the `EcommerceProvider` component and the `stripeAdapterClient` function. Wrap your application in the provider and pass in the Stripe adapter with your publishable key:
```ts
import { EcommerceProvider } from '@payloadcms/plugin-ecommerce/client/react'
import { stripeAdapterClient } from '@payloadcms/plugin-ecommerce/payments/stripe'
<EcommerceProvider
paymentMethods={[
stripeAdapterClient({
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || '',
}),
]}
>
{children}
</EcommerceProvider>
```
Then you can use the `usePayments` hook to access the `initiatePayment` and `confirmOrder` functions, see the [Frontend docs](./frontend#usePayments) for more details.
## Making your own Payment Adapter
You can make your own payment adapter by implementing the `PaymentAdapter` interface. This interface requires you to implement the following methods:
| Property | Type | Description |
| ----------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | `string` | The name of the payment method. This will be used to identify the payment method in the API and on the frontend. |
| `label` | `string` | (Optional) A human-readable label for the payment method. This will be used in the admin panel and on the frontend. |
| `initiatePayment` | `(args: InitiatePaymentArgs) => Promise<InitiatePaymentResult>` | The function that is called via the `/api/payments/{provider_name}/initiate` endpoint to initiate a payment for an order. [More](#initiatePayment) |
| `confirmOrder` | `(args: ConfirmOrderArgs) => Promise<void>` | The function that is called via the `/api/payments/{provider_name}/confirm-order` endpoint to confirm an order after a payment has been made. [More](#confirmOrder) |
| `endpoints` | `Endpoint[]` | (Optional) An array of endpoints to be bootstrapped to Payload's API in order to support the payment method. All API paths are relative to `/api/payments/{provider_name}` |
| `group` | `GroupField` | A group field config to be used in transactions to track the necessary data for the payment processor, eg. PaymentIntentID for Stripe. See [Payment Fields](#payment-fields) for more details. |
The arguments can be extended but should always include the `PaymentAdapterArgs` type which has the following types:
| Property | Type | Description |
| ---------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `label` | `string` | (Optional) Allow overriding the default UI label for this adaper. |
| `groupOverrides` | `FieldsOverride` | (Optional) Allow overriding the default fields of the payment group. See [Payment Fields](#payment-fields) for more details. |
#### initiatePayment
The `initiatePayment` function is called when a payment is initiated. At this step the transaction is created with a status "Processing", an abandoned purchaase will leave this transaction in this state. It receives an object with the following properties:
| Property | Type | Description |
| ------------------ | ---------------- | --------------------------------------------- |
| `transactionsSlug` | `Transaction` | The transaction being processed. |
| `data` | `object` | The cart associated with the transaction. |
| `customersSlug` | `string` | The customer associated with the transaction. |
| `req` | `PayloadRequest` | The Payload request object. |
The data object will contain the following properties:
| Property | Type | Description |
| ----------------- | --------- | ----------------------------------------------------------------------------------------------------------------- |
| `billingAddress` | `Address` | The billing address associated with the transaction. |
| `shippingAddress` | `Address` | (Optional) The shipping address associated with the transaction. If this is missing then use the billing address. |
| `cart` | `Cart` | The cart collection item. |
| `customerEmail` | `string` | In the case that `req.user` is missing, `customerEmail` should be required in order to process guest checkouts. |
| `currency` | `string` | The currency for the cart associated with the transaction. |
The return type then only needs to contain the following properties though the type supports any additional data returned as needed for the frontend:
| Property | Type | Description |
| --------- | -------- | ----------------------------------------------- |
| `message` | `string` | A success message to be returned to the client. |
At any point in the function you can throw an error to return a 4xx or 5xx response to the client.
A heavily simplified example of implementing `initiatePayment` could look like:
```ts
import {
PaymentAdapter,
PaymentAdapterArgs,
} from '@payloadcms/plugin-ecommerce'
import Stripe from 'stripe'
export const initiatePayment: NonNullable<PaymentAdapter>['initiatePayment'] =
async ({ data, req, transactionsSlug }) => {
const payload = req.payload
// Check for any required data
const currency = data.currency
const cart = data.cart
if (!currency) {
throw new Error('Currency is required.')
}
const stripe = new Stripe(secretKey)
try {
let customer = (
await stripe.customers.list({
email: customerEmail,
})
).data[0]
// Ensure stripe has a customer for this email
if (!customer?.id) {
customer = await stripe.customers.create({
email: customerEmail,
})
}
const shippingAddressAsString = JSON.stringify(shippingAddressFromData)
const paymentIntent = await stripe.paymentIntents.create()
// Create a transaction for the payment intent in the database
const transaction = await payload.create({
collection: transactionsSlug,
data: {},
})
// Return the client_secret so that the client can complete the payment
const returnData: InitiatePaymentReturnType = {
clientSecret: paymentIntent.client_secret || '',
message: 'Payment initiated successfully',
paymentIntentID: paymentIntent.id,
}
return returnData
} catch (error) {
payload.logger.error(error, 'Error initiating payment with Stripe')
throw new Error(
error instanceof Error
? error.message
: 'Unknown error initiating payment',
)
}
}
```
#### confirmOrder
The `confirmOrder` function is called after a payment is completed on the frontend and at this step the order is created in Payload. It receives the following properties:
| Property | Type | Description |
| ------------------ | ---------------- | ----------------------------------------- |
| `ordersSlug` | `string` | The orders collection slug. |
| `transactionsSlug` | `string` | The transactions collection slug. |
| `cartsSlug` | `string` | The carts collection slug. |
| `customersSlug` | `string` | The customers collection slug. |
| `data` | `object` | The cart associated with the transaction. |
| `req` | `PayloadRequest` | The Payload request object. |
The data object will contain any data the frontend chooses to send through and at a minimum the following:
| Property | Type | Description |
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------- |
| `customerEmail` | `string` | In the case that `req.user` is missing, `customerEmail` should be required in order to process guest checkouts. |
The return type can also contain any additional data with a minimum of the following:
| Property | Type | Description |
| --------------- | -------- | ----------------------------------------------- |
| `message` | `string` | A success message to be returned to the client. |
| `orderID` | `string` | The ID of the created order. |
| `transactionID` | `string` | The ID of the associated transaction. |
A heavily simplified example of implementing `confirmOrder` could look like:
```ts
import {
PaymentAdapter,
PaymentAdapterArgs,
} from '@payloadcms/plugin-ecommerce'
import Stripe from 'stripe'
export const confirmOrder: NonNullable<PaymentAdapter>['confirmOrder'] =
async ({
data,
ordersSlug = 'orders',
req,
transactionsSlug = 'transactions',
}) => {
const payload = req.payload
const customerEmail = data.customerEmail
const paymentIntentID = data.paymentIntentID as string
const stripe = new Stripe(secretKey)
try {
// Find our existing transaction by the payment intent ID
const transactionsResults = await payload.find({
collection: transactionsSlug,
where: {
'stripe.paymentIntentID': {
equals: paymentIntentID,
},
},
})
const transaction = transactionsResults.docs[0]
// Verify the payment intent exists and retrieve it
const paymentIntent =
await stripe.paymentIntents.retrieve(paymentIntentID)
// Create the order in the database
const order = await payload.create({
collection: ordersSlug,
data: {},
})
const timestamp = new Date().toISOString()
// Update the cart to mark it as purchased, this will prevent further updates to the cart
await payload.update({
id: cartID,
collection: 'carts',
data: {
purchasedAt: timestamp,
},
})
// Update the transaction with the order ID and mark as succeeded
await payload.update({
id: transaction.id,
collection: transactionsSlug,
data: {
order: order.id,
status: 'succeeded',
},
})
return {
message: 'Payment initiated successfully',
orderID: order.id,
transactionID: transaction.id,
}
} catch (error) {
payload.logger.error(error, 'Error initiating payment with Stripe')
}
}
```
#### Payment Fields
Payment fields are used primarily on the transactions collection to store information about the payment method used. Each payment adapter must provide a `group` field which will be used to store this information.
For example, the Stripe adapter provides the following group field:
```ts
const groupField: GroupField = {
name: 'stripe',
type: 'group',
admin: {
condition: (data) => {
const path = 'paymentMethod'
return data?.[path] === 'stripe'
},
},
fields: [
{
name: 'customerID',
type: 'text',
label: 'Stripe Customer ID',
},
{
name: 'paymentIntentID',
type: 'text',
label: 'Stripe PaymentIntent ID',
},
],
}
```
### Client side Payment Adapter
The client side adapter should implement the `PaymentAdapterClient` interface:
| Property | Type | Description |
| ----------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | `string` | The name of the payment method. This will be used to identify the payment method in the API and on the frontend. |
| `label` | `string` | (Optional) A human-readable label for the payment method. This can be used as a human readable format. |
| `initiatePayment` | `boolean` | Flag to toggle on the EcommerceProvider's ability to call the `/api/payments/{provider_name}/initiate` endpoint. If your payment method does not require this step, set this to `false`. |
| `confirmOrder` | `boolean` | Flag to toggle on the EcommerceProvider's ability to call the `/api/payments/{provider_name}/confirm-order` endpoint. If your payment method does not require this step, set this to `false`. |
And for the args use the `PaymentAdapterClientArgs` type:
| Property | Type | Description |
| -------- | -------- | ----------------------------------------------------------------- |
| `label` | `string` | (Optional) Allow overriding the default UI label for this adaper. |
## Best Practices
Always handle sensitive operations like creating payment intents and confirming payments on the server side. Use webhooks to listen for events from Stripe and update your orders accordingly. Never expose your secret key on the frontend. By default Nextjs will only expose environment variables prefixed with `NEXT_PUBLIC_` to the client.
While we validate the products and prices on the server side when creating a payment intent, you should override the validation function to add any additional checks you may need for your specific use case.
You are safe to pass the ID of a transaction to the frontend however you shouldn't pass any sensitive information or the transaction object itself.
When passing price information to your payment provider it should always come from the server and it should be verified against the products in your database. Never trust price information coming from the client.
When using webhooks, ensure that you verify the webhook signatures to confirm that the requests are genuinely from Stripe. This helps prevent unauthorized access and potential security vulnerabilities.

661
docs/ecommerce/plugin.mdx Normal file
View File

@@ -0,0 +1,661 @@
---
title: Ecommerce Plugin
label: Plugin
order: 20
desc: Add ecommerce functionality to your Payload CMS application with this plugin.
keywords: plugins, ecommerce, stripe, plugin, payload, cms, shop, payments
---
## Basic Usage
In the `plugins` array of your [Payload Config](https://payloadcms.com/docs/configuration/overview), call the plugin with:
```ts
import { buildConfig } from 'payload'
import { ecommercePlugin } from '@payloadcms/plugin-ecommerce'
const config = buildConfig({
collections: [
{
slug: 'pages',
fields: [],
},
],
plugins: [
ecommercePlugin({
// You must add your access control functions here
access: {
adminOnly,
adminOnlyFieldAccess,
adminOrCustomerOwner,
adminOrPublishedStatus,
customerOnlyFieldAccess,
},
customers: { slug: 'users' },
}),
],
})
export default config
```
## Options
| Option | Type | Description |
| -------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| `access` | `object` | Configuration to override the default access control, use this when checking for roles or multi tenancy. [More](#access) |
| `addresses` | `object` | Configuration for addresses collection and supported fields. [More](#addresses) |
| `carts` | `object` | Configuration for carts collection. [More](#carts) |
| `currencies` | `object` | Supported currencies by the store. [More](#currencies) |
| `customers` | `object` | Used to provide the customers slug. [More](#customers) |
| `inventory` | `boolean` `object` | Enable inventory tracking within Payload. Defaults to `true`. [More](#inventory) |
| `payments` | `object` | Configuring payments and supported payment methods. [More](#payments) |
| `products` | `object` | Configuration for products, variants collections and more. [More](#products) |
| `orders` | `object` | Configuration for orders collection. [More](#orders) |
| `transactions` | `boolean` `object` | Configuration for transactions collection. [More](#transactions) |
Note that the fields in overrides take a function that receives the default fields and returns an array of fields. This allows you to add fields to the collection.
```ts
ecommercePlugin({
access: {
adminOnly,
adminOnlyFieldAccess,
adminOrCustomerOwner,
adminOrPublishedStatus,
customerOnlyFieldAccess,
},
customers: {
slug: 'users',
},
payments: {
paymentMethods: [
stripeAdapter({
secretKey: process.env.STRIPE_SECRET_KEY!,
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOKS_SIGNING_SECRET!,
}),
],
},
products: {
variants: {
variantsCollection: VariantsCollection,
},
productsCollection: ProductsCollection,
},
})
```
## Access
The plugin requires access control functions in order to restrict permissions to certain collections or fields. You can override these functions by providing your own in the `access` option.
| Option | Type | Description |
| ------------------------- | ------------- | ------------------------------------------------------------------------- |
| `authenticatedOnly` | `Access` | Authenticated access only, provided by default. |
| `publicAccess` | `Access` | Public access, provided by default. |
| `adminOnly` | `Access` | Limited to only admin users. |
| `adminOnlyFieldAccess` | `FieldAccess` | Limited to only admin users, specifically for Field level access control. |
| `adminOrCustomerOwner` | `Access` | Is the owner of the document via the `customer` field or is an admin. |
| `adminOrPublishedStatus` | `Access` | The document is published or user is admin. |
| `customerOnlyFieldAccess` | `FieldAccess` | Limited to customers only, specifically for Field level access control. |
The default access control functions are:
```ts
access: {
authenticatedOnly: ({ req: { user } }) => Boolean(user),
publicAccess: () => true,
}
```
### authenticatedOnly
Access control to check if the user is authenticated. By default the following is provided:
```ts
authenticatedOnly: ({ req: { user } }) => Boolean(user)
```
### publicAccess
Access control to allow public access. By default the following is provided:
```ts
publicAccess: () => true
```
### adminOnly
Access control to check if the user has `admin` permissions.
Example:
```ts
adminOnly: ({ req: { user } }) => Boolean(user?.roles?.includes('admin'))
```
### adminOnlyFieldAccess
Field level access control to check if the user has `admin` permissions.
Example:
```ts
adminOnlyFieldAccess: ({ req: { user } }) =>
Boolean(user?.roles?.includes('admin'))
```
### adminOrCustomerOwner
Access control to check if the user has `admin` permissions or is the owner of the document via the `customer` field.
Example:
```ts
adminOrCustomerOwner: ({ req: { user } }) => {
if (user && Boolean(user?.roles?.includes('admin'))) {
return true
}
if (user?.id) {
return {
customer: {
equals: user.id,
},
}
}
return false
}
```
### adminOrPublishedStatus
Access control to check if the user has `admin` permissions or if the document is published.
Example:
```ts
adminOrPublishedStatus: ({ req: { user } }) => {
if (user && Boolean(user?.roles?.includes('admin'))) {
return true
}
return {
_status: {
equals: 'published',
},
}
}
```
### customerOnlyFieldAccess
Field level access control to check if the user has `customer` permissions.
Example:
```ts
customerOnlyFieldAccess: ({ req: { user } }) =>
Boolean(user?.roles?.includes('customer'))
```
## Addresses
The `addresses` option is used to configure the addresses collection and supported fields. Defaults to `true` which will create an `addresses` collection with default fields. It also takes an object:
| Option | Type | Description |
| ----------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `addressFields` | `FieldsOverride` | A function that is given the `defaultFields` as an argument and returns an array of fields. Use this to customise the supported fields for stored addresses. |
| `addressesCollectionOverride` | `CollectionOverride` | Allows you to override the collection for `addresses` with a function where you can access the `defaultCollection` as an argument. |
| `supportedCountries` | `CountryType[]` | An array of supported countries in [ISO 3166-1 alpha-2 format](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2). Defaults to all countries. |
You can add your own fields or modify the structure of the existing on in the collection. Example for overriding the default fields:
```ts
addresses: {
addressesCollectionOverride: ({ defaultCollection }) => ({
...defaultCollection,
fields: [
...defaultCollection.fields,
{
name: 'googleMapLocation',
label: 'Google Map Location',
type: 'text',
},
],
})
}
```
### supportedCountries
The `supportedCountries` option is an array of country codes in [ISO 3166-1 alpha-2 format](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2). This is used to limit the countries that can be selected when creating or updating an address. If not provided, all countries will be supported. Currently used for storing addresses only.
You can import the default list of countries from the plugin:
```ts
import { defaultCountries } from '@payloadcms/plugin-ecommerce/client/react'
```
## Carts
The `carts` option is used to configure the carts collection. Defaults to `true` which will create a `carts` collection with default fields. It also takes an object:
| Option | Type | Description |
| ------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `cartsCollectionOverride` | `CollectionOverride` | Allows you to override the collection for `carts` with a function where you can access the `defaultCollection` as an argument. |
You can add your own fields or modify the structure of the existing on in the collection. Example for overriding the default fields:
```ts
carts: {
cartsCollectionOverride: ({ defaultCollection }) => ({
...defaultCollection,
fields: [
...defaultCollection.fields,
{
name: 'notes',
label: 'Notes',
type: 'textarea',
},
],
})
}
```
Carts are created when a customer adds their first item to the cart. The cart is then updated as they add or remove items. The cart is linked to a _Customer_ via the `customer` field. If the user is authenticated, this will be set to their user ID. If the user is not authenticated, this will be `null`.
If the user is not authenticated, the cart ID is stored in local storage and used to fetch the cart on subsequent requests. Access control by default works so that if the user is not authenticated then they can only access carts that have no customer linked to them.
## Customers
The `customers` option is required and is used to provide the customers collection slug. This collection is used to link orders, carts, and addresses to a customer.
| Option | Type | Description |
| ------ | -------- | ------------------------------------- |
| `slug` | `string` | The slug of the customers collection. |
While it's recommended to use just one collection for customers and your editors, you can use any collection you want for your customers. Just make sure that your access control is checking for the correct collections as well.
## Currencies
The `currencies` option is used to configure the supported currencies by the store. Defaults to `true` which will support `USD`. It also takes an object:
| Option | Type | Description |
| --------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `supportedCurrencies` | `Currency[]` | An array of supported currencies by the store. Defaults to `USD`. See [Currencies](#currencies-list) for available currencies. |
| `defaultCurrency` | `string` | The default currency code to use for the store. Defaults to the first currency. Must be one of the `supportedCurrencies` codes. |
The `Currency` type is as follows:
```ts
type Currency = {
code: string // The currency code in ISO 4217 format, e.g. 'USD'
decimals: number // The number of decimal places for the currency, e.g. 2 for USD
label: string // A human-readable label for the currency, e.g. 'US Dollar'
symbol: string // The currency symbol, e.g. '$'
}
```
For example, to support JYP in addition to USD:
```ts
import { ecommercePlugin } from '@payloadcms/plugin-ecommerce'
import { USD } from '@payloadcms/plugin-ecommerce'
ecommercePlugin({
currencies: {
supportedCurrencies: [
USD,
{
code: 'JPY',
decimals: 0,
label: 'Japanese Yen',
symbol: '¥',
},
],
defaultCurrency: 'USD',
},
})
```
Note that adding a new currency could generate a new schema migration as it adds new prices fields in your products.
We currently support the following currencies out of the box:
- `USD`
- `EUR`
- `GBP`
You can import these from the plugin:
```ts
import { EUR } from '@payloadcms/plugin-ecommerce'
```
<Banner type="info">
Note that adding new currencies here does not automatically enable them in
your payment gateway. Make sure to enable the currencies in your payment
gateway dashboard as well.
</Banner>
## Inventory
The `inventory` option is used to enable or disable inventory tracking within Payload. It defaults to `true`. It also takes an object:
| Option | Type | Description |
| ----------- | -------- | ------------------------------------------------------------------------- |
| `fieldName` | `string` | Override the field name used to track inventory. Defaults to `inventory`. |
For now it's quite rudimentary tracking with no integrations to 3rd party services. It will simply add an `inventory` field to the `variants` collection and decrement the inventory when an order is placed.
## Payments
The `payments` option is used to configure payments and supported payment methods.
| Option | Type | Description |
| ---------------- | ------- | ------------------------------------------------------------------------------------------------- |
| `paymentMethods` | `array` | An array of payment method adapters. Currently, only Stripe is supported. [More](#stripe-adapter) |
### Payment adapters
The plugin supports payment adapters to integrate with different payment gateways. Currently, only the [Stripe adapter](#stripe-adapter) is available. Adapters will provide a client side version as well with slightly different arguments.
Every adapter supports the following arguments in addition to their own:
| Argument | Type | Description |
| ---------------- | ---------------------------------- | ----------------------------------------------------------------------- |
| `label` | `string` | Human readabale label for this payment adapter. |
| `groupOverrides` | `GroupField` with `FieldsOverride` | Use this to override the available fields for the payment adapter type. |
Client side base arguments are the following:
| Argument | Type | Description |
| -------- | -------- | ----------------------------------------------- |
| `label` | `string` | Human readabale label for this payment adapter. |
See the [Stripe adapter](#stripe-adapter) for an example of client side arguments and the [React section](#react) for usage.
#### `groupOverrides`
The `groupOverrides` option allows you to customize the fields that are available for a specific payment adapter. It takes a `GroupField` object with a `fields` function that receives the default fields and returns an array of fields.
These fields are stored in transactions and can be used to collect additional information for the payment method. Stripe, for example, will track the `paymentIntentID`.
Example for overriding the default fields:
```ts
payments: {
paymentMethods: [
stripeAdapter({
secretKey: process.env.STRIPE_SECRET_KEY,
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
webhookSecret: process.env.STRIPE_WEBHOOKS_SIGNING_SECRET,
groupOverrides: {
fields: ({ defaultFields }) => {
return [
...defaultFields,
{
name: 'customField',
label: 'Custom Field',
type: 'text',
},
]
}
}
}),
],
},
```
### Stripe Adapter
The Stripe adapter is used to integrate with the Stripe payment gateway. It requires a secret key, publishable key, and optionally webhook secret.
<Banner type="info">
Note that Payload will not install the Stripe SDK package for you
automatically, so you will need to install it yourself:
```
pnpm add stripe
```
</Banner>
| Argument | Type | Description |
| ---------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `secretKey` | `string` | Required for communicating with the Stripe API in the backend. |
| `publishableKey` | `string` | Required for communicating with the Stripe API in the client side. |
| `webhookSecret` | `string` | The webhook secret used to verify incoming webhook requests from Stripe. |
| `webhooks` | `WebhookHandler[]` | An array of webhook handlers to register within Payload's REST API for Stripe to callback. |
| `apiVersion` | `string` | The Stripe API version to use. See [docs](https://stripe.com/docs/api/versioning). This will be deprecated soon by Stripe's SDK, configure the API version in your Stripe Dashboard. |
| `appInfo` | `object` | The application info to pass to Stripe. See [docs](https://stripe.com/docs/api/app_info). |
```ts
import { stripeAdapter } from '@payloadcms/plugin-ecommerce/payments/stripe'
stripeAdapter({
secretKey: process.env.STRIPE_SECRET_KEY!,
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOKS_SIGNING_SECRET!,
})
```
#### Stripe `webhooks`
The `webhooks` option allows you to register custom webhook handlers for [Stripe events](https://docs.stripe.com/api/events). This is useful if you want to handle specific events that are not covered by the default handlers provided by the plugin.
```ts
stripeAdapter({
webhooks: {
'payment_intent.succeeded': ({ event, req }) => {
// Access to Payload's req object and event data
},
},
}),
```
#### Stripe client side
On the client side, you can use the `publishableKey` to initialize Stripe and handle payments. The client side version of the adapter only requires the `label` and `publishableKey` arguments. Never expose the `secretKey` or `webhookSecret` keys on the client side.
```ts
import { stripeAdapterClient } from '@payloadcms/plugin-ecommerce/payments/stripe'
<EcommerceProvider
paymentMethods={[
stripeAdapterClient({
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
}),
]}
>
{children}
</EcommerceProvider>
```
## Products
The `products` option is used to configure the products and variants collections. Defaults to `true` which will create `products` and `variants` collections with default fields. It also takes an object:
| Option | Type | Description |
| ---------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `productsCollectionOverride` | `CollectionOverride` | Allows you to override the collection for `products` with a function where you can access the `defaultCollection` as an argument. |
| `variants` | `boolean` `object` | Configuration for the variants collection. Defaults to true. [More](#variants) |
| `validation` | `ProductsValidation` | Customise the validation used for checking products or variants before a transaction is created or a payment can be confirmed. [More](#products-validation) |
You can add your own fields or modify the structure of the existing on in the collections. Example for overriding the default fields:
```ts
products: {
productsCollectionOverride: ({ defaultCollection }) => ({
...defaultCollection,
fields: [
...defaultCollection.fields,
{
name: 'notes',
label: 'Notes',
type: 'textarea',
},
],
})
}
```
### Variants
The `variants` option is used to configure the variants collection. It takes an object:
| Option | Type | Description |
| ---------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `variantsCollectionOverride` | `CollectionOverride` | Allows you to override the collection for `variants` with a function where you can access the `defaultCollection` as an argument. |
| `variantTypesCollectionOverride` | `CollectionOverride` | Allows you to override the collection for `variantTypes` with a function where you can access the `defaultCollection` as an argument. |
| `variantOptionsCollectionOverride` | `CollectionOverride` | Allows you to override the collection for `variantOptions` with a function where you can access the `defaultCollection` as an argument. |
You can add your own fields or modify the structure of the existing on in the collection. Example for overriding the default fields:
```ts
variants: {
variantsCollectionOverride: ({ defaultCollection }) => ({
...defaultCollection,
fields: [
...defaultCollection.fields,
{
name: 'customField',
label: 'Custom Field',
type: 'text',
},
],
})
}
```
The key differences between these collections:
- `variantTypes` are the types of variants that a product can have, e.g. Size, Color.
- `variantOptions` are the options for each variant type, e.g. Small, Medium, Large for Size.
- `variants` are the actual variants of a product, e.g. a T-Shirt in Size Small and Color Red.
### Products validation
We use an addition validation step when creating transactions or confirming payments to ensure that the products and variants being purchased are valid. This is to prevent issues such as purchasing a product that is out of stock or has been deleted.
You can customise this validation by providing your own validation function via the `validation` option which receives the following arguments:
| Option | Type | Description |
| ------------------ | ------------------ | -------------------------------------------------------------------------------------------------------- |
| `currenciesConfig` | `CurrenciesConfig` | The full currencies configuration provided in the plugin options. |
| `product` | `TypedCollection` | The product being purchased. |
| `variant` | `TypedCollection` | The variant being purchased, if a variant was selected for the product otherwise it will be `undefined`. |
| `quantity` | `number` | The quantity being purchased. |
| `currency` | `string` | The currency code being used for the purchase. |
The function should throw an error if the product or variant is not valid. If the function does not throw an error, the product or variant is considered valid.
The default validation function checks for the following:
- A currency is provided.
- The product or variant has a price in the selected currency.
- The product or variant has enough inventory for the requested quantity.
```ts
export const defaultProductsValidation: ProductsValidation = ({
currenciesConfig,
currency,
product,
quantity = 1,
variant,
}) => {
if (!currency) {
throw new Error('Currency must be provided for product validation.')
}
const priceField = `priceIn${currency.toUpperCase()}`
if (variant) {
if (!variant[priceField]) {
throw new Error(
`Variant with ID ${variant.id} does not have a price in ${currency}.`,
)
}
if (
variant.inventory === 0 ||
(variant.inventory && variant.inventory < quantity)
) {
throw new Error(
`Variant with ID ${variant.id} is out of stock or does not have enough inventory.`,
)
}
} else if (product) {
// Validate the product's details only if the variant is not provided as it can have its own inventory and price
if (!product[priceField]) {
throw new Error(`Product does not have a price in.`, {
cause: { code: MissingPrice, codes: [product.id, currency] },
})
}
if (
product.inventory === 0 ||
(product.inventory && product.inventory < quantity)
) {
throw new Error(
`Product is out of stock or does not have enough inventory.`,
{
cause: { code: OutOfStock, codes: [product.id] },
},
)
}
}
}
```
## Orders
The `orders` option is used to configure the orders collection. Defaults to `true` which will create an `orders` collection with default fields. It also takes an object:
| Option | Type | Description |
| -------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `ordersCollectionOverride` | `CollectionOverride` | Allows you to override the collection for `orders` with a function where you can access the `defaultCollection` as an argument. |
You can add your own fields or modify the structure of the existing on in the collection. Example for overriding the default fields:
```ts
orders: {
ordersCollectionOverride: ({ defaultCollection }) => ({
...defaultCollection,
fields: [
...defaultCollection.fields,
{
name: 'notes',
label: 'Notes',
type: 'textarea',
},
],
})
}
```
## Transactions
The `transactions` option is used to configure the transactions collection. Defaults to `true` which will create a `transactions` collection with default fields. It also takes an object:
| Option | Type | Description |
| -------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `transactionsCollectionOverride` | `CollectionOverride` | Allows you to override the collection for `transactions` with a function where you can access the `defaultCollection` as an argument. |
You can add your own fields or modify the structure of the existing on in the collection. Example for overriding the default fields:
```ts
transactions: {
transactionsCollectionOverride: ({ defaultCollection }) => ({
...defaultCollection,
fields: [
...defaultCollection.fields,
{
name: 'notes',
label: 'Notes',
type: 'textarea',
},
],
})
}
```

View File

@@ -56,6 +56,7 @@ Payload maintains a set of Official Plugins that solve for some of the common us
- [SEO](./seo) - [SEO](./seo)
- [Stripe](./stripe) - [Stripe](./stripe)
- [Import/Export](./import-export) - [Import/Export](./import-export)
- [Ecommerce](../ecommerce/overview)
You can also [build your own plugin](./build-your-own) to easily extend Payload's functionality in some other way. Once your plugin is ready, consider [sharing it with the community](#community-plugins). You can also [build your own plugin](./build-your-own) to easily extend Payload's functionality in some other way. Once your plugin is ready, consider [sharing it with the community](#community-plugins).

View File

@@ -11,12 +11,12 @@
"bf": "pnpm run build:force", "bf": "pnpm run build:force",
"build": "pnpm run build:core", "build": "pnpm run build:core",
"build:admin-bar": "turbo build --filter \"@payloadcms/admin-bar\"", "build:admin-bar": "turbo build --filter \"@payloadcms/admin-bar\"",
"build:all": "turbo build --filter \"!blank\" --filter \"!website\"", "build:all": "turbo build --filter \"!blank\" --filter \"!website\" --filter \"!ecommerce\"",
"build:app": "next build", "build:app": "next build",
"build:app:analyze": "cross-env ANALYZE=true next build", "build:app:analyze": "cross-env ANALYZE=true next build",
"build:bundle-for-analysis": "turbo run build:bundle-for-analysis", "build:bundle-for-analysis": "turbo run build:bundle-for-analysis",
"build:clean": "pnpm clean:build", "build:clean": "pnpm clean:build",
"build:core": "turbo build --filter \"!@payloadcms/plugin-*\" --filter \"!@payloadcms/storage-*\" --filter \"!blank\" --filter \"!website\"", "build:core": "turbo build --filter \"!@payloadcms/plugin-*\" --filter \"!@payloadcms/storage-*\" --filter \"!blank\" --filter \"!website\" --filter \"!ecommerce\"",
"build:core:force": "pnpm clean:build && pnpm build:core --no-cache --force", "build:core:force": "pnpm clean:build && pnpm build:core --no-cache --force",
"build:create-payload-app": "turbo build --filter create-payload-app", "build:create-payload-app": "turbo build --filter create-payload-app",
"build:db-d1-sqlite": "turbo build --filter \"@payloadcms/db-d1-sqlite\"", "build:db-d1-sqlite": "turbo build --filter \"@payloadcms/db-d1-sqlite\"",
@@ -39,6 +39,7 @@
"build:payload": "turbo build --filter payload", "build:payload": "turbo build --filter payload",
"build:payload-cloud": "turbo build --filter \"@payloadcms/payload-cloud\"", "build:payload-cloud": "turbo build --filter \"@payloadcms/payload-cloud\"",
"build:plugin-cloud-storage": "turbo build --filter \"@payloadcms/plugin-cloud-storage\"", "build:plugin-cloud-storage": "turbo build --filter \"@payloadcms/plugin-cloud-storage\"",
"build:plugin-ecommerce": "turbo build --filter \"@payloadcms/plugin-ecommerce\"",
"build:plugin-form-builder": "turbo build --filter \"@payloadcms/plugin-form-builder\"", "build:plugin-form-builder": "turbo build --filter \"@payloadcms/plugin-form-builder\"",
"build:plugin-import-export": "turbo build --filter \"@payloadcms/plugin-import-export\"", "build:plugin-import-export": "turbo build --filter \"@payloadcms/plugin-import-export\"",
"build:plugin-multi-tenant": "turbo build --filter \"@payloadcms/plugin-multi-tenant\"", "build:plugin-multi-tenant": "turbo build --filter \"@payloadcms/plugin-multi-tenant\"",
@@ -82,9 +83,9 @@
"docker:start": "docker compose -f test/docker-compose.yml up -d", "docker:start": "docker compose -f test/docker-compose.yml up -d",
"docker:stop": "docker compose -f test/docker-compose.yml down", "docker:stop": "docker compose -f test/docker-compose.yml down",
"force:build": "pnpm run build:core:force", "force:build": "pnpm run build:core:force",
"lint": "turbo run lint --log-order=grouped --continue --filter \"!blank\" --filter \"!website\"", "lint": "turbo run lint --log-order=grouped --continue --filter \"!blank\" --filter \"!website\" --filter \"!ecommerce\"",
"lint-staged": "lint-staged", "lint-staged": "lint-staged",
"lint:fix": "turbo run lint:fix --log-order=grouped --continue --filter \"!blank\" --filter \"!website\"", "lint:fix": "turbo run lint:fix --log-order=grouped --continue --filter \"!blank\" --filter \"!website\" --filter \"!ecommerce\"",
"obliterate-playwright-cache-macos": "rm -rf ~/Library/Caches/ms-playwright && find /System/Volumes/Data/private/var/folders -type d -name 'playwright*' -exec rm -rf {} +", "obliterate-playwright-cache-macos": "rm -rf ~/Library/Caches/ms-playwright && find /System/Volumes/Data/private/var/folders -type d -name 'playwright*' -exec rm -rf {} +",
"prepare": "husky", "prepare": "husky",
"prepare-run-test-against-prod": "pnpm bf && rm -rf test/packed && rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..", "prepare-run-test-against-prod": "pnpm bf && rm -rf test/packed && rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",

View File

@@ -129,9 +129,22 @@ export async function createProject(
const spinner = p.spinner() const spinner = p.spinner()
spinner.start('Checking latest Payload version...') spinner.start('Checking latest Payload version...')
const payloadVersion = await getLatestPackageVersion({ packageName: 'payload' }) // Allows overriding the installed Payload version instead of installing the latest
const versionFromCli = cliArgs['--version']
spinner.stop(`Found latest version of Payload ${payloadVersion}`) let payloadVersion: string
if (versionFromCli) {
await verifyVersionForPackage({ version: versionFromCli })
payloadVersion = versionFromCli
spinner.stop(`Using provided version of Payload ${payloadVersion}`)
} else {
payloadVersion = await getLatestPackageVersion({ packageName: 'payload' })
spinner.stop(`Found latest version of Payload ${payloadVersion}`)
}
await updatePackageJSON({ latestVersion: payloadVersion, projectDir, projectName }) await updatePackageJSON({ latestVersion: payloadVersion, projectDir, projectName })
@@ -283,3 +296,34 @@ async function getLatestPackageVersion({
throw error throw error
} }
} }
/**
* Verifies that the specified version of a package exists on the NPM registry.
*
* Throws an error if the version does not exist.
*/
async function verifyVersionForPackage({
packageName = 'payload',
version,
}: {
/**
* Package name to fetch the latest version for based on the NPM registry URL
*
* Eg. for `'payload'`, it will fetch the version from `https://registry.npmjs.org/payload`
*
* @default 'payload'
*/
packageName?: string
version: string
}): Promise<void> {
try {
const response = await fetch(`https://registry.npmjs.org/${packageName}/${version}`)
if (response.status !== 200) {
throw new Error(`No ${version} version found for package: ${packageName}`)
}
} catch (error) {
console.error('Error verifying Payload version:', error)
throw error
}
}

View File

@@ -27,6 +27,12 @@ export function getValidTemplates(): ProjectTemplate[] {
description: 'Website Template', description: 'Website Template',
url: `https://github.com/payloadcms/payload/templates/website#main`, url: `https://github.com/payloadcms/payload/templates/website#main`,
}, },
{
name: 'ecommerce',
type: 'starter',
description: 'Ecommerce template',
url: 'https://github.com/payloadcms/payload/templates/ecommerce#feat/ecommerce-template',
},
{ {
name: 'plugin', name: 'plugin',
type: 'plugin', type: 'plugin',

View File

@@ -46,6 +46,7 @@ export class Main {
'--name': String, '--name': String,
'--secret': String, '--secret': String,
'--template': String, '--template': String,
'--version': String, // Allows overriding the installed Payload version instead of installing the latest
// Next.js // Next.js
'--init-next': Boolean, // TODO: Is this needed if we detect if inside Next.js project? '--init-next': Boolean, // TODO: Is this needed if we detect if inside Next.js project?

7
packages/plugin-ecommerce/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules
.env
dist
demo/uploads
build
.DS_Store
package-lock.json

View File

@@ -0,0 +1,12 @@
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp
**/docs/**
tsconfig.json

View File

@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
},
"transform": {
"react": {
"runtime": "automatic",
"pragmaFrag": "React.Fragment",
"throwIfNamespace": true,
"development": false,
"useBuiltins": true
}
}
},
"module": {
"type": "es6"
}
}

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2018-2025 Payload CMS, Inc. <info@payloadcms.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,3 @@
# Payload Ecommerce
A set of utilities... more to come

View File

@@ -0,0 +1,18 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -0,0 +1,142 @@
{
"name": "@payloadcms/plugin-ecommerce",
"version": "3.55.1",
"description": "Ecommerce plugin for Payload",
"keywords": [
"payload",
"cms",
"plugin",
"typescript",
"react",
"ecommerce"
],
"repository": {
"type": "git",
"url": "https://github.com/payloadcms/payload.git",
"directory": "packages/plugin-ecommerce"
},
"license": "MIT",
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
"maintainers": [
{
"name": "Payload",
"email": "info@payloadcms.com",
"url": "https://payloadcms.com"
}
],
"type": "module",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./types": {
"import": "./src/exports/types.ts",
"types": "./src/exports/types.ts",
"default": "./src/exports/types.ts"
},
"./payments/stripe": {
"import": "./src/exports/payments/stripe.ts",
"types": "./src/exports/payments/stripe.ts",
"default": "./src/exports/payments/stripe.ts"
},
"./rsc": {
"import": "./src/exports/rsc.ts",
"types": "./src/exports/rsc.ts",
"default": "./src/exports/rsc.ts"
},
"./translations": {
"import": "./src/exports/translations.ts",
"types": "./src/exports/translations.ts",
"default": "./src/exports/translations.ts"
},
"./client": {
"import": "./src/exports/client/index.ts",
"types": "./src/exports/client/index.ts",
"default": "./src/exports/client/index.ts"
},
"./client/react": {
"import": "./src/exports/client/react.ts",
"types": "./src/exports/client/react.ts",
"default": "./src/exports/client/react.ts"
}
},
"main": "./src/index.tsx",
"types": "./src/index.tsx",
"files": [
"dist"
],
"scripts": {
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"clean": "rimraf -g {dist,*.tsbuildinfo}",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"prepublishOnly": "pnpm clean && pnpm turbo build"
},
"dependencies": {
"@payloadcms/translations": "workspace:*",
"@payloadcms/ui": "workspace:*",
"qs-esm": "7.0.2"
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@payloadcms/next": "workspace:*",
"@types/json-schema": "7.0.15",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.2",
"payload": "workspace:*",
"stripe": "18.3.0"
},
"peerDependencies": {
"payload": "workspace:*",
"react": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020",
"react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020"
},
"publishConfig": {
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./types": {
"import": "./dist/exports/types.js",
"types": "./dist/exports/types.d.ts",
"default": "./dist/exports/types.js"
},
"./payments/stripe": {
"import": "./dist/exports/payments/stripe.js",
"types": "./dist/exports/payments/stripe.d.ts",
"default": "./dist/exports/payments/stripe.js"
},
"./rsc": {
"import": "./dist/exports/rsc.js",
"types": "./dist/exports/rsc.d.ts",
"default": "./dist/exports/rsc.js"
},
"./client": {
"import": "./dist/exports/client/index.js",
"types": "./dist/exports/client/index.d.ts",
"default": "./dist/exports/client/index.js"
},
"./client/react": {
"import": "./dist/exports/client/react.js",
"types": "./dist/exports/client/react.d.ts",
"default": "./dist/exports/client/react.js"
},
"./translations": {
"import": "./dist/exports/translations.js",
"types": "./dist/exports/translations.d.ts",
"default": "./dist/exports/translations.js"
}
},
"main": "./dist/index.js",
"registry": "https://registry.npmjs.org/",
"types": "./dist/index.d.ts"
},
"homepage:": "https://payloadcms.com"
}

View File

@@ -0,0 +1,102 @@
import type { CollectionConfig, Field } from 'payload'
import type { AccessConfig, CountryType } from '../../types/index.js'
import { defaultCountries } from './defaultCountries.js'
import { beforeChange } from './hooks/beforeChange.js'
type Props = {
access: {
adminOrCustomerOwner: AccessConfig['adminOrCustomerOwner']
authenticatedOnly: NonNullable<AccessConfig['authenticatedOnly']>
customerOnlyFieldAccess: AccessConfig['customerOnlyFieldAccess']
}
/**
* Array of fields used for capturing the address data. Use this over overrides to customise the fields here as it's reused across the plugin.
*/
addressFields: Field[]
/**
* Slug of the customers collection, defaults to 'users'.
*/
customersSlug?: string
supportedCountries?: CountryType[]
}
export const createAddressesCollection: (props: Props) => CollectionConfig = (props) => {
const {
access: { adminOrCustomerOwner, authenticatedOnly, customerOnlyFieldAccess },
addressFields,
customersSlug = 'users',
} = props || {}
const { supportedCountries: supportedCountriesFromProps } = props || {}
const supportedCountries = supportedCountriesFromProps || defaultCountries
const hasOnlyOneCountry = supportedCountries && supportedCountries.length === 1
const fields: Field[] = [
{
name: 'customer',
type: 'relationship',
admin: {
position: 'sidebar',
},
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:customer'),
relationTo: customersSlug,
},
...addressFields.map((field) => {
if ('name' in field && field.name === 'country') {
return {
name: 'country',
type: 'select',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:addressCountry'),
options: supportedCountries || defaultCountries,
required: true,
...(supportedCountries && supportedCountries?.[0] && hasOnlyOneCountry
? {
defaultValue: supportedCountries?.[0].value,
}
: {}),
} as Field
}
return field
}),
]
const baseConfig: CollectionConfig = {
slug: 'addresses',
access: {
create: authenticatedOnly,
delete: adminOrCustomerOwner,
read: adminOrCustomerOwner,
update: adminOrCustomerOwner,
},
admin: {
description: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:addressesCollectionDescription'),
group: 'Ecommerce',
hidden: true,
useAsTitle: 'createdAt',
},
fields,
hooks: {
beforeChange: [beforeChange({ customerOnlyFieldAccess })],
},
labels: {
plural: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:addresses'),
singular: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:address'),
},
timestamps: true,
}
return { ...baseConfig }
}

View File

@@ -0,0 +1,83 @@
import type { Field } from 'payload'
export const defaultAddressFields: () => Field[] = () => {
return [
{
name: 'title',
type: 'text',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:addressTitle'),
},
{
name: 'firstName',
type: 'text',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:addressFirstName'),
},
{
name: 'lastName',
type: 'text',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:addressLastName'),
},
{
name: 'company',
type: 'text',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:addressCompany'),
},
{
name: 'addressLine1',
type: 'text',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:addressLine1'),
},
{
name: 'addressLine2',
type: 'text',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:addressLine2'),
},
{
name: 'city',
type: 'text',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:addressCity'),
},
{
name: 'state',
type: 'text',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:addressState'),
},
{
name: 'postalCode',
type: 'text',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:addressPostalCode'),
},
{
name: 'country',
type: 'text',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:addressCountry'),
},
{
name: 'phone',
type: 'text',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:addressPhone'),
},
]
}

View File

@@ -0,0 +1,50 @@
import type { CountryType } from '../../types/index.js'
/**
* Default list of countries supported for forms and payments.
* This can be overriden or reused in other parts of the application.
*
* The label is the human-readable name of the country, and the value is the ISO 3166-1 alpha-2 code.
*/
export const defaultCountries: CountryType[] = [
{ label: 'United States', value: 'US' },
{ label: 'United Kingdom', value: 'GB' },
{ label: 'Canada', value: 'CA' },
{ label: 'Australia', value: 'AU' },
{ label: 'Austria', value: 'AT' },
{ label: 'Belgium', value: 'BE' },
{ label: 'Brazil', value: 'BR' },
{ label: 'Bulgaria', value: 'BG' },
{ label: 'Cyprus', value: 'CY' },
{ label: 'Czech Republic', value: 'CZ' },
{ label: 'Denmark', value: 'DK' },
{ label: 'Estonia', value: 'EE' },
{ label: 'Finland', value: 'FI' },
{ label: 'France', value: 'FR' },
{ label: 'Germany', value: 'DE' },
{ label: 'Greece', value: 'GR' },
{ label: 'Hong Kong', value: 'HK' },
{ label: 'Hungary', value: 'HU' },
{ label: 'India', value: 'IN' },
{ label: 'Ireland', value: 'IE' },
{ label: 'Italy', value: 'IT' },
{ label: 'Japan', value: 'JP' },
{ label: 'Latvia', value: 'LV' },
{ label: 'Lithuania', value: 'LT' },
{ label: 'Luxembourg', value: 'LU' },
{ label: 'Malaysia', value: 'MY' },
{ label: 'Malta', value: 'MT' },
{ label: 'Mexico', value: 'MX' },
{ label: 'Netherlands', value: 'NL' },
{ label: 'New Zealand', value: 'NZ' },
{ label: 'Norway', value: 'NO' },
{ label: 'Poland', value: 'PL' },
{ label: 'Portugal', value: 'PT' },
{ label: 'Romania', value: 'RO' },
{ label: 'Singapore', value: 'SG' },
{ label: 'Slovakia', value: 'SK' },
{ label: 'Slovenia', value: 'SI' },
{ label: 'Spain', value: 'ES' },
{ label: 'Sweden', value: 'SE' },
{ label: 'Switzerland', value: 'CH' },
]

View File

@@ -0,0 +1,19 @@
import type { CollectionBeforeChangeHook } from 'payload'
import type { AccessConfig } from '../../../types/index.js'
interface Props {
customerOnlyFieldAccess: AccessConfig['customerOnlyFieldAccess']
}
export const beforeChange: (args: Props) => CollectionBeforeChangeHook =
({ customerOnlyFieldAccess }) =>
async ({ data, req }) => {
const isCustomer = await customerOnlyFieldAccess({ req })
// Ensure that the customer field is set to the current user's ID if the user is a customer.
// Admins can set to any customer.
if (req.user && isCustomer) {
data.customer = req.user.id
}
}

View File

@@ -0,0 +1,51 @@
import type { CollectionBeforeChangeHook } from 'payload'
type Props = {
productsSlug: string
variantsSlug: string
}
export const beforeChangeCart: (args: Props) => CollectionBeforeChangeHook =
({ productsSlug, variantsSlug }) =>
async ({ data, req }) => {
// Update subtotal based on items in the cart
if (data.items && Array.isArray(data.items)) {
const priceField = `priceIn${data.currency}`
let subtotal = 0
for (const item of data.items) {
if (item.variant) {
const id = typeof item.variant === 'object' ? item.variant.id : item.variant
const variant = await req.payload.findByID({
id,
collection: variantsSlug,
depth: 0,
select: {
[priceField]: true,
},
})
subtotal += variant[priceField] * item.quantity
} else {
const id = typeof item.product === 'object' ? item.product.id : item.product
const product = await req.payload.findByID({
id,
collection: productsSlug,
depth: 0,
select: {
[priceField]: true,
},
})
subtotal += product[priceField] * item.quantity
}
}
data.subtotal = subtotal
} else {
data.subtotal = 0
}
}

View File

@@ -0,0 +1,178 @@
import type { CollectionConfig, Field } from 'payload'
import type { AccessConfig, CurrenciesConfig } from '../../types/index.js'
import { amountField } from '../../fields/amountField.js'
import { cartItemsField } from '../../fields/cartItemsField.js'
import { currencyField } from '../../fields/currencyField.js'
import { beforeChangeCart } from './beforeChange.js'
import { statusBeforeRead } from './statusBeforeRead.js'
type Props = {
access: {
adminOrCustomerOwner: NonNullable<AccessConfig['adminOrCustomerOwner']>
publicAccess: NonNullable<AccessConfig['publicAccess']>
}
currenciesConfig?: CurrenciesConfig
/**
* Slug of the customers collection, defaults to 'users'.
*/
customersSlug?: string
/**
* Enables support for variants in the cart.
* Defaults to false.
*/
enableVariants?: boolean
/**
* Slug of the products collection, defaults to 'products'.
*/
productsSlug?: string
/**
* Slug of the variants collection, defaults to 'variants'.
*/
variantsSlug?: string
}
export const createCartsCollection: (props: Props) => CollectionConfig = (props) => {
const {
access: { adminOrCustomerOwner, publicAccess },
currenciesConfig,
customersSlug = 'users',
enableVariants = false,
productsSlug = 'products',
variantsSlug = 'variants',
} = props || {}
const fields: Field[] = [
cartItemsField({
enableVariants,
overrides: {
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:items'),
labels: {
plural: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:items'),
singular: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:item'),
},
},
productsSlug,
variantsSlug,
}),
{
name: 'customer',
type: 'relationship',
admin: {
position: 'sidebar',
},
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:customer'),
relationTo: customersSlug,
},
{
name: 'purchasedAt',
type: 'date',
admin: {
date: { pickerAppearance: 'dayAndTime' },
position: 'sidebar',
},
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:purchasedAt'),
},
{
name: 'status',
type: 'select',
admin: {
position: 'sidebar',
readOnly: true,
},
hooks: {
afterRead: [statusBeforeRead],
},
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:status'),
options: [
{
// @ts-expect-error - translations are not typed in plugins yet
label: ({ t }) => t('plugin-ecommerce:active'),
value: 'active',
},
{
// @ts-expect-error - translations are not typed in plugins yet
label: ({ t }) => t('plugin-ecommerce:purchased'),
value: 'purchased',
},
{
// @ts-expect-error - translations are not typed in plugins yet
label: ({ t }) => t('plugin-ecommerce:abandoned'),
value: 'abandoned',
},
],
virtual: true,
},
...(currenciesConfig
? [
{
type: 'row',
admin: { position: 'sidebar' },
fields: [
amountField({
currenciesConfig,
overrides: {
name: 'subtotal',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:subtotal'),
},
}),
currencyField({
currenciesConfig,
}),
],
} as Field,
]
: []),
]
const baseConfig: CollectionConfig = {
slug: 'carts',
access: {
create: publicAccess,
delete: adminOrCustomerOwner,
read: adminOrCustomerOwner,
update: adminOrCustomerOwner,
},
admin: {
description: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:cartsCollectionDescription'),
group: 'Ecommerce',
useAsTitle: 'createdAt',
},
fields,
hooks: {
beforeChange: [
// This hook can be used to update the subtotal before saving the cart
beforeChangeCart({ productsSlug, variantsSlug }),
],
},
labels: {
plural: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:carts'),
singular: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:cart'),
},
timestamps: true,
}
return { ...baseConfig }
}

View File

@@ -0,0 +1,20 @@
import type { FieldHook } from 'payload'
export const statusBeforeRead: FieldHook = ({ data }) => {
if (data?.purchasedAt) {
return 'purchased'
}
if (data?.createdAt) {
const timeNow = new Date().getTime()
const createdAt = new Date(data.createdAt).getTime()
const differenceToCheck = 7 * 24 * 60 * 60 * 1000 // 7 days
if (timeNow - createdAt < differenceToCheck) {
// If the cart was created within the last 7 days, it is considered 'active'
return 'active'
}
}
return 'abandoned'
}

View File

@@ -0,0 +1,223 @@
import type { CollectionConfig, Field } from 'payload'
import type { AccessConfig, CurrenciesConfig } from '../../types/index.js'
import { amountField } from '../../fields/amountField.js'
import { cartItemsField } from '../../fields/cartItemsField.js'
import { currencyField } from '../../fields/currencyField.js'
type Props = {
access: {
adminOnly: NonNullable<AccessConfig['adminOnly']>
adminOnlyFieldAccess: NonNullable<AccessConfig['adminOnlyFieldAccess']>
adminOrCustomerOwner: NonNullable<AccessConfig['adminOrCustomerOwner']>
}
/**
* Array of fields used for capturing the shipping address data.
*/
addressFields?: Field[]
currenciesConfig?: CurrenciesConfig
/**
* Slug of the customers collection, defaults to 'users'.
*/
customersSlug?: string
enableVariants?: boolean
/**
* Slug of the products collection, defaults to 'products'.
*/
productsSlug?: string
/**
* Slug of the transactions collection, defaults to 'transactions'.
*/
transactionsSlug?: string
/**
* Slug of the variants collection, defaults to 'variants'.
*/
variantsSlug?: string
}
export const createOrdersCollection: (props: Props) => CollectionConfig = (props) => {
const {
access: { adminOnly, adminOnlyFieldAccess, adminOrCustomerOwner },
addressFields,
currenciesConfig,
customersSlug = 'users',
enableVariants = false,
productsSlug = 'products',
transactionsSlug = 'transactions',
variantsSlug = 'variants',
} = props || {}
const fields: Field[] = [
{
type: 'tabs',
tabs: [
{
fields: [
cartItemsField({
enableVariants,
overrides: {
name: 'items',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:items'),
labels: {
plural: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:items'),
singular: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:item'),
},
},
productsSlug,
variantsSlug,
}),
],
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:orderDetails'),
},
{
fields: [
...(addressFields
? [
{
name: 'shippingAddress',
type: 'group',
fields: addressFields,
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:shippingAddress'),
} as Field,
]
: []),
],
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:shipping'),
},
],
},
{
name: 'customer',
type: 'relationship',
admin: {
position: 'sidebar',
},
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:customer'),
relationTo: customersSlug,
},
{
name: 'customerEmail',
type: 'email',
admin: {
position: 'sidebar',
},
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:customerEmail'),
},
{
name: 'transactions',
type: 'relationship',
access: {
create: adminOnlyFieldAccess,
read: adminOnlyFieldAccess,
update: adminOnlyFieldAccess,
},
admin: {
position: 'sidebar',
},
hasMany: true,
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:transactions'),
relationTo: transactionsSlug,
},
{
name: 'status',
type: 'select',
admin: {
position: 'sidebar',
},
defaultValue: 'processing',
interfaceName: 'OrderStatus',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:status'),
options: [
{
// @ts-expect-error - translations are not typed in plugins yet
label: ({ t }) => t('plugin-ecommerce:processing'),
value: 'processing',
},
{
// @ts-expect-error - translations are not typed in plugins yet
label: ({ t }) => t('plugin-ecommerce:completed'),
value: 'completed',
},
{
// @ts-expect-error - translations are not typed in plugins yet
label: ({ t }) => t('plugin-ecommerce:cancelled'),
value: 'cancelled',
},
{
// @ts-expect-error - translations are not typed in plugins yet
label: ({ t }) => t('plugin-ecommerce:refunded'),
value: 'refunded',
},
],
},
...(currenciesConfig
? [
{
type: 'row',
admin: {
position: 'sidebar',
},
fields: [
amountField({
currenciesConfig,
}),
currencyField({
currenciesConfig,
}),
],
} as Field,
]
: []),
]
const baseConfig: CollectionConfig = {
slug: 'orders',
access: {
create: adminOnly,
delete: adminOnly,
read: adminOrCustomerOwner,
update: adminOnly,
},
admin: {
description: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:ordersCollectionDescription'),
group: 'Ecommerce',
useAsTitle: 'createdAt',
},
fields,
labels: {
plural: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:orders'),
singular: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:order'),
},
timestamps: true,
}
return { ...baseConfig }
}

View File

@@ -0,0 +1,90 @@
import type { CollectionConfig } from 'payload'
import type { AccessConfig, CurrenciesConfig, InventoryConfig } from '../../types/index.js'
import { inventoryField } from '../../fields/inventoryField.js'
import { pricesField } from '../../fields/pricesField.js'
import { variantsFields } from '../../fields/variantsFields.js'
type Props = {
access: {
adminOnly: NonNullable<AccessConfig['adminOnly']>
adminOrPublishedStatus: NonNullable<AccessConfig['adminOrPublishedStatus']>
}
currenciesConfig: CurrenciesConfig
enableVariants?: boolean
/**
* Adds in an inventory field to the product and its variants. This is useful for tracking inventory levels.
* Defaults to true.
*/
inventory?: boolean | InventoryConfig
/**
* Slug of the variants collection, defaults to 'variants'.
*/
variantsSlug?: string
/**
* Slug of the variant types collection, defaults to 'variantTypes'.
*/
variantTypesSlug?: string
}
export const createProductsCollection: (props: Props) => CollectionConfig = (props) => {
const {
access: { adminOnly, adminOrPublishedStatus },
currenciesConfig,
enableVariants = false,
inventory = true,
variantsSlug = 'variants',
variantTypesSlug = 'variantTypes',
} = props || {}
const fields = [
...(inventory
? [
inventoryField({
overrides: {
admin: {
condition: ({ enableVariants }) => !enableVariants,
},
},
}),
]
: []),
...(enableVariants ? variantsFields({ variantsSlug, variantTypesSlug }) : []),
...(currenciesConfig ? [...pricesField({ currenciesConfig })] : []),
]
const baseConfig: CollectionConfig = {
slug: 'products',
access: {
create: adminOnly,
delete: adminOnly,
read: adminOrPublishedStatus,
update: adminOnly,
},
admin: {
defaultColumns: [
...(currenciesConfig ? ['prices'] : []),
...(enableVariants ? ['variants'] : []),
],
group: 'Ecommerce',
},
fields,
labels: {
plural: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:products'),
singular: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:product'),
},
trash: true,
versions: {
drafts: {
autosave: true,
},
},
}
return baseConfig
}

View File

@@ -0,0 +1,221 @@
import type { CollectionConfig, Field } from 'payload'
import type { AccessConfig, CurrenciesConfig, PaymentAdapter } from '../../types/index.js'
import { amountField } from '../../fields/amountField.js'
import { cartItemsField } from '../../fields/cartItemsField.js'
import { currencyField } from '../../fields/currencyField.js'
import { statusField } from '../../fields/statusField.js'
type Props = {
access: {
adminOnly: NonNullable<AccessConfig['adminOnly']>
}
/**
* Array of fields used for capturing the billing address.
*/
addressFields?: Field[]
/**
* Slug of the carts collection, defaults to 'carts'.
*/
cartsSlug?: string
currenciesConfig?: CurrenciesConfig
/**
* Slug of the customers collection, defaults to 'users'.
*/
customersSlug?: string
/**
* Enable variants in the transactions collection.
*/
enableVariants?: boolean
/**
* Slug of the orders collection, defaults to 'orders'.
*/
ordersSlug?: string
paymentMethods?: PaymentAdapter[]
/**
* Slug of the products collection, defaults to 'products'.
*/
productsSlug?: string
/**
* Slug of the variants collection, defaults to 'variants'.
*/
variantsSlug?: string
}
export const createTransactionsCollection: (props: Props) => CollectionConfig = (props) => {
const {
access: { adminOnly },
addressFields,
cartsSlug = 'carts',
currenciesConfig,
customersSlug = 'users',
enableVariants = false,
ordersSlug = 'orders',
paymentMethods,
productsSlug = 'products',
variantsSlug = 'variants',
} = props || {}
const fields: Field[] = [
{
type: 'tabs',
tabs: [
{
fields: [
cartItemsField({
enableVariants,
overrides: {
name: 'items',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:items'),
labels: {
plural: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:items'),
singular: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:item'),
},
},
productsSlug,
variantsSlug,
}),
...(paymentMethods?.length && paymentMethods.length > 0
? [
{
name: 'paymentMethod',
type: 'select',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:paymentMethod'),
options: paymentMethods.map((method) => ({
label: method.label ?? method.name,
value: method.name,
})),
} as Field,
...(paymentMethods.map((method) => method.group) || []),
]
: []),
],
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:transactionDetails'),
},
{
fields: [
...(addressFields
? [
{
name: 'billingAddress',
type: 'group',
fields: addressFields,
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:billingAddress'),
} as Field,
]
: []),
],
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:billing'),
},
],
},
statusField({
overrides: {
admin: {
position: 'sidebar',
},
},
}),
{
name: 'customer',
type: 'relationship',
admin: {
position: 'sidebar',
},
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:customer'),
relationTo: customersSlug,
},
{
name: 'customerEmail',
type: 'email',
admin: {
position: 'sidebar',
},
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:customerEmail'),
},
{
name: 'order',
type: 'relationship',
admin: {
position: 'sidebar',
},
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:order'),
relationTo: ordersSlug,
},
{
name: 'cart',
type: 'relationship',
admin: {
position: 'sidebar',
},
relationTo: cartsSlug,
},
...(currenciesConfig
? [
{
type: 'row',
admin: {
position: 'sidebar',
},
fields: [
amountField({
currenciesConfig,
}),
currencyField({
currenciesConfig,
}),
],
} as Field,
]
: []),
]
const baseConfig: CollectionConfig = {
slug: 'transactions',
access: {
create: adminOnly,
delete: adminOnly,
read: adminOnly,
update: adminOnly,
},
admin: {
defaultColumns: ['createdAt', 'customer', 'order', 'amount', 'status'],
description: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:transactionsCollectionDescription'),
group: 'Ecommerce',
},
fields,
labels: {
plural: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:transactions'),
singular: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:transaction'),
},
}
return { ...baseConfig }
}

View File

@@ -0,0 +1,72 @@
import type { CollectionConfig, Field } from 'payload'
import type { AccessConfig } from '../../types/index.js'
type Props = {
access: {
adminOnly: NonNullable<AccessConfig['adminOnly']>
publicAccess: NonNullable<AccessConfig['publicAccess']>
}
/**
* Slug of the variant types collection, defaults to 'variantTypes'.
*/
variantTypesSlug?: string
}
export const createVariantOptionsCollection: (props: Props) => CollectionConfig = (props) => {
const {
access: { adminOnly, publicAccess },
variantTypesSlug = 'variantTypes',
} = props || {}
const fields: Field[] = [
{
name: 'variantType',
type: 'relationship',
admin: {
readOnly: true,
},
relationTo: variantTypesSlug,
required: true,
},
{
name: 'label',
type: 'text',
required: true,
},
{
name: 'value',
type: 'text',
admin: {
description: 'should be defaulted or dynamic based on label',
},
required: true,
},
]
const baseConfig: CollectionConfig = {
slug: 'variantOptions',
access: {
create: adminOnly,
delete: adminOnly,
read: publicAccess,
update: adminOnly,
},
admin: {
group: false,
useAsTitle: 'label',
},
fields,
labels: {
plural: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:variantOptions'),
singular: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:variantOption'),
},
trash: true,
}
return { ...baseConfig }
}

View File

@@ -0,0 +1,68 @@
import type { CollectionConfig, Field } from 'payload'
import type { AccessConfig } from '../../types/index.js'
type Props = {
access: {
adminOnly: NonNullable<AccessConfig['adminOnly']>
publicAccess: NonNullable<AccessConfig['publicAccess']>
}
/**
* Slug of the variant options collection, defaults to 'variantOptions'.
*/
variantOptionsSlug?: string
}
export const createVariantTypesCollection: (props: Props) => CollectionConfig = (props) => {
const {
access: { adminOnly, publicAccess },
variantOptionsSlug = 'variantOptions',
} = props || {}
const fields: Field[] = [
{
name: 'label',
type: 'text',
required: true,
},
{
name: 'name',
type: 'text',
required: true,
},
{
name: 'options',
type: 'join',
collection: variantOptionsSlug,
maxDepth: 2,
on: 'variantType',
orderable: true,
},
]
const baseConfig: CollectionConfig = {
slug: 'variantTypes',
access: {
create: adminOnly,
delete: adminOnly,
read: publicAccess,
update: adminOnly,
},
admin: {
group: false,
useAsTitle: 'label',
},
fields,
labels: {
plural: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:variantTypes'),
singular: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:variantType'),
},
trash: true,
}
return { ...baseConfig }
}

View File

@@ -0,0 +1,51 @@
import type { CollectionBeforeChangeHook } from 'payload'
type Props = {
productsSlug: string
variantOptionsSlug: string
}
export const variantsCollectionBeforeChange: (args: Props) => CollectionBeforeChangeHook =
({ productsSlug, variantOptionsSlug }) =>
async ({ data, req }) => {
if (data?.options?.length && data.options.length > 0) {
const titleArray: string[] = []
const productID = data.product
const product = await req.payload.findByID({
id: productID,
collection: productsSlug,
depth: 0,
select: {
title: true,
variantTypes: true,
},
})
if (product.title && typeof product.title === 'string') {
titleArray.push(product.title)
}
for (const option of data.options) {
const variantOption = await req.payload.findByID({
id: option,
collection: variantOptionsSlug,
depth: 0,
select: {
label: true,
},
})
if (!variantOption) {
continue
}
if (variantOption.label && typeof variantOption.label === 'string') {
titleArray.push(variantOption.label)
}
}
data.title = titleArray.join(' — ')
}
return data
}

View File

@@ -0,0 +1,72 @@
import type { Validate } from 'payload'
type Props = {
productsCollectionSlug?: string
}
export const validateOptions: (props?: Props) => Validate =
(props) =>
async (values, { data, req }) => {
const { productsCollectionSlug = 'products' } = props || {}
const { t } = req
if (!values || values.length === 0) {
// @ts-expect-error - TODO: Fix types
return t('ecommerce:variantOptionsRequired')
}
const productID = data.product
if (!productID) {
// @ts-expect-error - TODO: Fix types
return t('ecommerce:productRequired')
}
const product = await req.payload.findByID({
id: productID,
collection: productsCollectionSlug,
depth: 1,
joins: {
variants: {
where: {
id: {
not_equals: data.id, // exclude the current variant from the search
},
},
},
},
select: {
variants: true,
variantTypes: true,
},
user: req.user,
})
// @ts-expect-error - TODO: Fix types
const variants = product.variants?.docs ?? []
// @ts-expect-error - TODO: Fix types
if (values.length < product?.variantTypes?.length) {
// @ts-expect-error - TODO: Fix types
return t('ecommerce:variantOptionsRequiredAll')
}
if (variants.length > 0) {
const existingOptions: (number | string)[][] = []
variants.forEach((variant: any) => {
existingOptions.push(variant.options)
})
const exists = existingOptions.some(
(combo) => combo.length === values.length && combo.every((val) => values.includes(val)),
)
if (exists) {
// @ts-expect-error - TODO: Fix types
return t('ecommerce:variantOptionsAlreadyExists')
}
}
return true
}

View File

@@ -0,0 +1,131 @@
import type { CollectionConfig, Field } from 'payload'
import type { AccessConfig, CurrenciesConfig, InventoryConfig } from '../../../types/index.js'
import { inventoryField } from '../../../fields/inventoryField.js'
import { pricesField } from '../../../fields/pricesField.js'
import { variantsCollectionBeforeChange as beforeChange } from './hooks/beforeChange.js'
import { validateOptions } from './hooks/validateOptions.js'
type Props = {
access: {
adminOnly: NonNullable<AccessConfig['adminOnly']>
adminOrPublishedStatus: NonNullable<AccessConfig['adminOrPublishedStatus']>
}
currenciesConfig?: CurrenciesConfig
/**
* Enables inventory tracking for variants. Defaults to true.
*/
inventory?: boolean | InventoryConfig
/**
* Slug of the products collection, defaults to 'products'.
*/
productsSlug?: string
/**
* Slug of the variant options collection, defaults to 'variantOptions'.
*/
variantOptionsSlug?: string
}
export const createVariantsCollection: (props: Props) => CollectionConfig = (props) => {
const {
access: { adminOnly, adminOrPublishedStatus },
currenciesConfig,
inventory = true,
productsSlug = 'products',
variantOptionsSlug = 'variantOptions',
} = props || {}
const { supportedCurrencies } = currenciesConfig || {}
const fields: Field[] = [
{
name: 'title',
type: 'text',
admin: {
description:
'Used for administrative purposes, not shown to customers. This is populated by default.',
},
},
{
name: 'product',
type: 'relationship',
admin: {
position: 'sidebar',
readOnly: true,
},
relationTo: productsSlug,
required: true,
},
{
// This might need to be a custom component, to show a selector for each variant that is
// enabled on the parent product
// - separate select inputs, each showing only a specific variant (w/ options)
// - it will save data to the DB as IDs in this relationship field
// and needs a validate function as well which enforces that the options are fully specified, and accurate
name: 'options',
type: 'relationship',
admin: {
components: {
Field: {
path: '@payloadcms/plugin-ecommerce/rsc#VariantOptionsSelector',
},
},
},
hasMany: true,
label: 'Variant options',
relationTo: variantOptionsSlug,
required: true,
validate: validateOptions(),
},
...(inventory ? [inventoryField()] : []),
]
if (supportedCurrencies?.length && supportedCurrencies.length > 0) {
const currencyOptions: string[] = []
supportedCurrencies.forEach((currency) => {
currencyOptions.push(currency.code)
})
if (currenciesConfig) {
fields.push(...pricesField({ currenciesConfig }))
}
}
const baseConfig: CollectionConfig = {
slug: 'variants',
access: {
create: adminOnly,
delete: adminOnly,
read: adminOrPublishedStatus,
update: adminOnly,
},
admin: {
description: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:variantsCollectionDescription'),
group: false,
useAsTitle: 'title',
},
fields,
hooks: {
beforeChange: [beforeChange({ productsSlug, variantOptionsSlug })],
},
labels: {
plural: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:variants'),
singular: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:variant'),
},
trash: true,
versions: {
drafts: {
autosave: true,
},
},
}
return baseConfig
}

View File

@@ -0,0 +1,22 @@
import type { Currency } from '../types/index.js'
export const EUR: Currency = {
code: 'EUR',
decimals: 2,
label: 'Euro',
symbol: '€',
}
export const USD: Currency = {
code: 'USD',
decimals: 2,
label: 'US Dollar',
symbol: '$',
}
export const GBP: Currency = {
code: 'GBP',
decimals: 2,
label: 'British Pound',
symbol: '£',
}

View File

@@ -0,0 +1,222 @@
import { addDataAndFileToRequest, type DefaultDocumentIDType, type Endpoint } from 'payload'
import type { CurrenciesConfig, PaymentAdapter, ProductsValidation } from '../types/index.js'
type Args = {
/**
* The slug of the carts collection, defaults to 'carts'.
*/
cartsSlug?: string
currenciesConfig: CurrenciesConfig
/**
* The slug of the customers collection, defaults to 'users'.
*/
customersSlug?: string
/**
* The slug of the orders collection, defaults to 'orders'.
*/
ordersSlug?: string
paymentMethod: PaymentAdapter
/**
* The slug of the products collection, defaults to 'products'.
*/
productsSlug?: string
/**
* Customise the validation used for checking products or variants before a transaction is created.
*/
productsValidation?: ProductsValidation
/**
* The slug of the transactions collection, defaults to 'transactions'.
*/
transactionsSlug?: string
/**
* The slug of the variants collection, defaults to 'variants'.
*/
variantsSlug?: string
}
type ConfirmOrderHandler = (args: Args) => Endpoint['handler']
/**
* Handles the endpoint for initiating payments. We will handle checking the amount and product and variant prices here before it is sent to the payment provider.
* This is the first step in the payment process.
*/
export const confirmOrderHandler: ConfirmOrderHandler =
({
cartsSlug = 'carts',
currenciesConfig,
customersSlug = 'users',
ordersSlug = 'orders',
paymentMethod,
productsSlug = 'products',
productsValidation,
transactionsSlug = 'transactions',
variantsSlug = 'variants',
}) =>
async (req) => {
await addDataAndFileToRequest(req)
const data = req.data
const payload = req.payload
const user = req.user
let currency: string = currenciesConfig.defaultCurrency
let cartID: DefaultDocumentIDType = data?.cartID
let cart = undefined
let customerEmail: string = user?.email ?? ''
if (user) {
if (user.cart?.docs && Array.isArray(user.cart.docs) && user.cart.docs.length > 0) {
if (!cartID && user.cart.docs[0]) {
// Use the user's cart instead
if (typeof user.cart.docs[0] === 'object') {
cartID = user.cart.docs[0].id
cart = user.cart.docs[0]
} else {
cartID = user.cart.docs[0]
}
}
}
} else {
// Get the email from the data if user is not available
if (data?.customerEmail && typeof data.customerEmail === 'string') {
customerEmail = data.customerEmail
} else {
return Response.json(
{
message: 'A customer email is required to make a purchase.',
},
{
status: 400,
},
)
}
}
if (!cart) {
if (cartID) {
cart = await payload.findByID({
id: cartID,
collection: cartsSlug,
depth: 2,
overrideAccess: false,
select: {
id: true,
currency: true,
customerEmail: true,
items: true,
subtotal: true,
},
user,
})
if (!cart) {
return Response.json(
{
message: `Cart with ID ${cartID} not found.`,
},
{
status: 404,
},
)
}
} else {
return Response.json(
{
message: 'Cart ID is required.',
},
{
status: 400,
},
)
}
}
if (cart.currency && typeof cart.currency === 'string') {
currency = cart.currency
}
// Ensure the currency is provided or inferred in some way
if (!currency) {
return Response.json(
{
message: 'Currency is required.',
},
{
status: 400,
},
)
}
try {
const paymentResponse = await paymentMethod.confirmOrder({
customersSlug,
data: {
...data,
customerEmail,
},
ordersSlug,
req,
transactionsSlug,
})
if (paymentResponse.transactionID) {
const transaction = await payload.findByID({
id: paymentResponse.transactionID,
collection: transactionsSlug,
depth: 0,
select: {
id: true,
items: true,
},
})
if (transaction && Array.isArray(transaction.items) && transaction.items.length > 0) {
for (const item of transaction.items) {
if (item.variant) {
const id = typeof item.variant === 'object' ? item.variant.id : item.variant
await payload.db.updateOne({
id,
collection: variantsSlug,
data: {
inventory: {
$inc: item.quantity * -1,
},
},
})
} else if (item.product) {
const id = typeof item.product === 'object' ? item.product.id : item.product
await payload.db.updateOne({
id,
collection: productsSlug,
data: {
inventory: {
$inc: item.quantity * -1,
},
},
})
}
}
}
}
if ('paymentResponse.transactionID' in paymentResponse && paymentResponse.transactionID) {
delete (paymentResponse as Partial<typeof paymentResponse>).transactionID
}
return Response.json(paymentResponse)
} catch (error) {
payload.logger.error(error, 'Error confirming order.')
return Response.json(
{
message: 'Error confirming order.',
},
{
status: 500,
},
)
}
}

View File

@@ -0,0 +1,334 @@
import { addDataAndFileToRequest, type DefaultDocumentIDType, type Endpoint } from 'payload'
import type {
CurrenciesConfig,
PaymentAdapter,
ProductsValidation,
SanitizedEcommercePluginConfig,
} from '../types/index.js'
import { defaultProductsValidation } from '../utilities/defaultProductsValidation.js'
type Args = {
/**
* The slug of the carts collection, defaults to 'carts'.
*/
cartsSlug?: string
currenciesConfig: CurrenciesConfig
/**
* The slug of the customers collection, defaults to 'users'.
*/
customersSlug?: string
/**
* Track inventory stock for the products and variants.
* Accepts an object to override the default field name.
*/
inventory?: SanitizedEcommercePluginConfig['inventory']
paymentMethod: PaymentAdapter
/**
* The slug of the products collection, defaults to 'products'.
*/
productsSlug?: string
/**
* Customise the validation used for checking products or variants before a transaction is created.
*/
productsValidation?: ProductsValidation
/**
* The slug of the transactions collection, defaults to 'transactions'.
*/
transactionsSlug?: string
/**
* The slug of the variants collection, defaults to 'variants'.
*/
variantsSlug?: string
}
type InitiatePayment = (args: Args) => Endpoint['handler']
/**
* Handles the endpoint for initiating payments. We will handle checking the amount and product and variant prices here before it is sent to the payment provider.
* This is the first step in the payment process.
*/
export const initiatePaymentHandler: InitiatePayment =
({
cartsSlug = 'carts',
currenciesConfig,
customersSlug = 'users',
paymentMethod,
productsSlug = 'products',
productsValidation,
transactionsSlug = 'transactions',
variantsSlug = 'variants',
}) =>
async (req) => {
await addDataAndFileToRequest(req)
const data = req.data
const payload = req.payload
const user = req.user
let currency: string = currenciesConfig.defaultCurrency
let cartID: DefaultDocumentIDType = data?.cartID
let cart = undefined
const billingAddress = data?.billingAddress
const shippingAddress = data?.shippingAddress
let customerEmail: string = user?.email ?? ''
if (user) {
if (user.cart?.docs && Array.isArray(user.cart.docs) && user.cart.docs.length > 0) {
if (!cartID && user.cart.docs[0]) {
// Use the user's cart instead
if (typeof user.cart.docs[0] === 'object') {
cartID = user.cart.docs[0].id
cart = user.cart.docs[0]
} else {
cartID = user.cart.docs[0]
}
}
}
} else {
// Get the email from the data if user is not available
if (data?.customerEmail && typeof data.customerEmail === 'string') {
customerEmail = data.customerEmail
} else {
return Response.json(
{
message: 'A customer email is required to make a purchase.',
},
{
status: 400,
},
)
}
}
if (!cart) {
if (cartID) {
cart = await payload.findByID({
id: cartID,
collection: cartsSlug,
depth: 2,
overrideAccess: false,
select: {
id: true,
currency: true,
customerEmail: true,
items: true,
subtotal: true,
},
user,
})
if (!cart) {
return Response.json(
{
message: `Cart with ID ${cartID} not found.`,
},
{
status: 404,
},
)
}
} else {
return Response.json(
{
message: 'Cart ID is required.',
},
{
status: 400,
},
)
}
}
if (cart.currency && typeof cart.currency === 'string') {
currency = cart.currency
}
// Ensure the currency is provided or inferred in some way
if (!currency) {
return Response.json(
{
message: 'Currency is required.',
},
{
status: 400,
},
)
}
// Ensure the selected currency is supported
if (
!currenciesConfig.supportedCurrencies.find(
(c) => c.code.toLocaleLowerCase() === currency.toLocaleLowerCase(),
)
) {
return Response.json(
{
message: `Currency ${currency} is not supported.`,
},
{
status: 400,
},
)
}
// Verify the cart is available and items are present in an array
if (!cart || !cart.items || !Array.isArray(cart.items) || cart.items.length === 0) {
return Response.json(
{
message: 'Cart is required and must contain at least one item.',
},
{
status: 400,
},
)
}
for (const item of cart.items) {
// Target field to check the price based on the currency so we can validate the total
const priceField = `priceIn${currency.toUpperCase()}`
const quantity = item.quantity || 1
// If the item has a product but no variant, we assume the product has a price in the specified currency
if (item.product && !item.variant) {
const id = typeof item.product === 'object' ? item.product.id : item.product
const product = await payload.findByID({
id,
collection: productsSlug,
depth: 0,
select: {
inventory: true,
[priceField]: true,
},
})
if (!product) {
return Response.json(
{
message: `Product with ID ${item.product} not found.`,
},
{
status: 404,
},
)
}
try {
if (productsValidation) {
await productsValidation({ currenciesConfig, currency, product, quantity })
} else {
await defaultProductsValidation({
currenciesConfig,
currency,
product,
quantity,
})
}
} catch (error) {
payload.logger.error(
error,
'Error validating product or variant during payment initiation.',
)
return Response.json(
{
message: error,
...(error instanceof Error ? { cause: error.cause } : {}),
},
{
status: 400,
},
)
}
if (item.variant) {
const id = typeof item.variant === 'object' ? item.variant.id : item.variant
const variant = await payload.findByID({
id,
collection: variantsSlug,
depth: 0,
select: {
inventory: true,
[priceField]: true,
},
})
if (!variant) {
return Response.json(
{
message: `Variant with ID ${item.variant} not found.`,
},
{
status: 404,
},
)
}
try {
if (productsValidation) {
await productsValidation({
currenciesConfig,
currency,
product: item.product,
quantity,
variant,
})
} else {
await defaultProductsValidation({
currenciesConfig,
currency,
product: item.product,
quantity,
variant,
})
}
} catch (error) {
payload.logger.error(
error,
'Error validating product or variant during payment initiation.',
)
return Response.json(
{
message: error,
},
{
status: 400,
},
)
}
}
}
}
try {
const paymentResponse = await paymentMethod.initiatePayment({
customersSlug,
data: {
billingAddress,
cart,
currency,
customerEmail,
shippingAddress,
},
req,
transactionsSlug,
})
return Response.json(paymentResponse)
} catch (error) {
payload.logger.error(error, 'Error initiating payment.')
return Response.json(
{
message: 'Error initiating payment.',
},
{
status: 500,
},
)
}
}

View File

@@ -0,0 +1,2 @@
export { PriceCell } from '../../ui/PriceCell/index.js'
export { PriceRowLabel } from '../../ui/PriceRowLabel/index.js'

View File

@@ -0,0 +1,11 @@
export { defaultCountries } from '../../collections/addresses/defaultCountries.js'
export { EUR, GBP, USD } from '../../currencies/index.js'
export {
EcommerceProvider,
useAddresses,
useCart,
useCurrency,
useEcommerce,
usePayments,
} from '../../react/provider/index.js'

View File

@@ -0,0 +1 @@
export { stripeAdapter, stripeAdapterClient } from '../../payments/adapters/stripe/index.js'

View File

@@ -0,0 +1,2 @@
export { PriceInput } from '../ui/PriceInput/index.js'
export { VariantOptionsSelector } from '../ui/VariantOptionsSelector/index.js'

View File

@@ -0,0 +1 @@
export { en } from '../translations/en.js'

View File

@@ -0,0 +1,18 @@
export type {
CollectionOverride,
CollectionSlugMap,
CountryType,
CurrenciesConfig,
Currency,
EcommerceCollections,
EcommerceContextType,
EcommercePluginConfig,
PaymentAdapter,
PaymentAdapterArgs,
PaymentAdapterClient,
PaymentAdapterClientArgs,
ProductsValidation,
SanitizedEcommercePluginConfig,
} from '../types/index.js'
export type { TypedEcommerce } from '../types/utilities.js'

View File

@@ -0,0 +1,50 @@
import type { NumberField } from 'payload'
import type { CurrenciesConfig, Currency } from '../types/index.js'
type Props = {
currenciesConfig: CurrenciesConfig
/**
* Use this specific currency for the field.
*/
currency?: Currency
overrides?: Partial<NumberField>
}
export const amountField: (props: Props) => NumberField = ({
currenciesConfig,
currency,
overrides,
}) => {
// @ts-expect-error - issue with payload types
const field: NumberField = {
name: 'amount',
type: 'number',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:amount'),
...overrides,
admin: {
components: {
Cell: {
clientProps: {
currenciesConfig,
currency,
},
path: '@payloadcms/plugin-ecommerce/client#PriceCell',
},
Field: {
clientProps: {
currenciesConfig,
currency,
},
path: '@payloadcms/plugin-ecommerce/rsc#PriceInput',
},
...overrides?.admin?.components,
},
...overrides?.admin,
},
}
return field
}

View File

@@ -0,0 +1,87 @@
import type { ArrayField, Field } from 'payload'
import type { CurrenciesConfig } from '../types/index.js'
import { amountField } from './amountField.js'
import { currencyField } from './currencyField.js'
type Props = {
/**
* Include this in order to enable support for currencies per item in the cart.
*/
currenciesConfig?: CurrenciesConfig
enableVariants?: boolean
/**
* Enables individual prices for each item in the cart.
* Defaults to false.
*/
individualPrices?: boolean
overrides?: Partial<ArrayField>
/**
* Slug of the products collection, defaults to 'products'.
*/
productsSlug?: string
/**
* Slug of the variants collection, defaults to 'variants'.
*/
variantsSlug?: string
}
export const cartItemsField: (props?: Props) => ArrayField = (props) => {
const {
currenciesConfig,
enableVariants = false,
individualPrices,
overrides,
productsSlug = 'products',
variantsSlug = 'variants',
} = props || {}
const field: ArrayField = {
name: 'items',
type: 'array',
admin: {
initCollapsed: true,
},
fields: [
{
name: 'product',
type: 'relationship',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:product'),
relationTo: productsSlug,
},
...(enableVariants
? [
{
name: 'variant',
type: 'relationship',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:variant'),
relationTo: variantsSlug,
} as Field,
]
: []),
{
name: 'quantity',
type: 'number',
defaultValue: 1,
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:quantity'),
min: 1,
required: true,
},
...(currenciesConfig && individualPrices ? [amountField({ currenciesConfig })] : []),
...(currenciesConfig ? [currencyField({ currenciesConfig })] : []),
],
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:cart'),
...overrides,
}
return field
}

View File

@@ -0,0 +1,39 @@
import type { SelectField } from 'payload'
import type { CurrenciesConfig } from '../types/index.js'
type Props = {
currenciesConfig: CurrenciesConfig
overrides?: Partial<SelectField>
}
export const currencyField: (props: Props) => SelectField = ({ currenciesConfig, overrides }) => {
const options = currenciesConfig.supportedCurrencies.map((currency) => {
const label = currency.label ? `${currency.label} (${currency.code})` : currency.code
return {
label,
value: currency.code,
}
})
const defaultValue =
(currenciesConfig.defaultCurrency ?? currenciesConfig.supportedCurrencies.length === 1)
? currenciesConfig.supportedCurrencies[0]?.code
: undefined
// @ts-expect-error - issue with payload types
const field: SelectField = {
name: 'currency',
type: 'select',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:currency'),
...(defaultValue && { defaultValue }),
options,
...overrides,
admin: { readOnly: currenciesConfig.supportedCurrencies.length === 1, ...overrides?.admin },
}
return field
}

View File

@@ -0,0 +1,22 @@
import type { NumberField } from 'payload'
type Props = {
overrides?: Partial<NumberField>
}
export const inventoryField: (props?: Props) => NumberField = (props) => {
const { overrides } = props || {}
// @ts-expect-error - issue with payload types
const field: NumberField = {
name: 'inventory',
type: 'number',
defaultValue: 0,
// @ts-expect-error - translations are not typed in plugins yet
label: ({ t }) => t('plugin-ecommerce:inventory'),
min: 0,
...(overrides || {}),
}
return field
}

View File

@@ -0,0 +1,70 @@
import type { GroupField } from 'payload'
import type { CurrenciesConfig } from '../types/index.js'
import { amountField } from './amountField.js'
type Props = {
/**
* Use this to specify a path for the condition.
*/
conditionalPath?: string
currenciesConfig: CurrenciesConfig
}
export const pricesField: (props: Props) => GroupField[] = ({
conditionalPath,
currenciesConfig,
}) => {
const currencies = currenciesConfig.supportedCurrencies
const fields: GroupField[] = currencies.map((currency) => {
const name = `priceIn${currency.code}`
const path = conditionalPath ? `${conditionalPath}.${name}Enabled` : `${name}Enabled`
return {
type: 'group',
admin: {
description: 'Prices for this product in different currencies.',
},
fields: [
{
type: 'row',
fields: [
{
name: `${name}Enabled`,
type: 'checkbox',
admin: {
style: {
alignSelf: 'baseline',
flex: '0 0 auto',
},
},
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:enableCurrencyPrice', { currency: currency.code }),
},
amountField({
currenciesConfig,
currency,
overrides: {
name,
admin: {
condition: (_, siblingData) => Boolean(siblingData?.[path]),
description: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:productPriceDescription'),
},
// @ts-expect-error - translations are not typed in plugins yet
label: ({ t }) => t('plugin-ecommerce:priceIn', { currency: currency.code }),
},
}),
],
},
],
}
})
return fields
}

View File

@@ -0,0 +1,57 @@
import type { SelectField } from 'payload'
export const statusOptions: SelectField['options'] = [
{
// @ts-expect-error - translations are not typed in plugins yet
label: ({ t }) => t('plugin-ecommerce:pending'),
value: 'pending',
},
{
// @ts-expect-error - translations are not typed in plugins yet
label: ({ t }) => t('plugin-ecommerce:succeeded'),
value: 'succeeded',
},
{
// @ts-expect-error - translations are not typed in plugins yet
label: ({ t }) => t('plugin-ecommerce:failed'),
value: 'failed',
},
{
// @ts-expect-error - translations are not typed in plugins yet
label: ({ t }) => t('plugin-ecommerce:cancelled'),
value: 'cancelled',
},
{
// @ts-expect-error - translations are not typed in plugins yet
label: ({ t }) => t('plugin-ecommerce:expired'),
value: 'expired',
},
{
// @ts-expect-error - translations are not typed in plugins yet
label: ({ t }) => t('plugin-ecommerce:refunded'),
value: 'refunded',
},
]
type Props = {
overrides?: Partial<SelectField>
}
export const statusField: (props?: Props) => SelectField = (props) => {
const { overrides } = props || {}
// @ts-expect-error - issue with payload types
const field: SelectField = {
name: 'status',
type: 'select',
defaultValue: 'pending',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:status'),
options: statusOptions,
required: true,
...overrides,
}
return field
}

View File

@@ -0,0 +1,61 @@
import type { Field } from 'payload'
type Props = {
/**
* Slug of the variants collection, defaults to 'variants'.
*/
variantsSlug?: string
/**
* Slug of the variant types collection, defaults to 'variantTypes'.
*/
variantTypesSlug?: string
}
export const variantsFields: (props: Props) => Field[] = ({
variantsSlug = 'variants',
variantTypesSlug = 'variantTypes',
}) => {
const fields: Field[] = [
{
name: 'enableVariants',
type: 'checkbox',
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:enableVariants'),
},
{
name: 'variantTypes',
type: 'relationship',
admin: {
condition: ({ enableVariants }) => Boolean(enableVariants),
},
hasMany: true,
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:variantTypes'),
relationTo: variantTypesSlug,
},
{
name: 'variants',
type: 'join',
admin: {
condition: ({ enableVariants, variantTypes }) => {
const enabledVariants = Boolean(enableVariants)
const hasManyVariantTypes = Array.isArray(variantTypes) && variantTypes.length > 0
return enabledVariants && hasManyVariantTypes
},
defaultColumns: ['title', 'options', 'inventory', 'prices', '_status'],
disableListColumn: true,
},
collection: variantsSlug,
label: ({ t }) =>
// @ts-expect-error - translations are not typed in plugins yet
t('plugin-ecommerce:availableVariants'),
maxDepth: 2,
on: 'product',
},
]
return fields
}

View File

@@ -0,0 +1,373 @@
import type { Config, Endpoint } from 'payload'
import { deepMergeSimple } from 'payload/shared'
import type { EcommercePluginConfig, SanitizedEcommercePluginConfig } from './types/index.js'
import { createAddressesCollection } from './collections/addresses/createAddressesCollection.js'
import { createCartsCollection } from './collections/carts/createCartsCollection.js'
import { createOrdersCollection } from './collections/orders/createOrdersCollection.js'
import { createProductsCollection } from './collections/products/createProductsCollection.js'
import { createTransactionsCollection } from './collections/transactions/createTransactionsCollection.js'
import { createVariantOptionsCollection } from './collections/variants/createVariantOptionsCollection.js'
import { createVariantsCollection } from './collections/variants/createVariantsCollection/index.js'
import { createVariantTypesCollection } from './collections/variants/createVariantTypesCollection.js'
import { confirmOrderHandler } from './endpoints/confirmOrder.js'
import { initiatePaymentHandler } from './endpoints/initiatePayment.js'
import { translations } from './translations/index.js'
import { getCollectionSlugMap } from './utilities/getCollectionSlugMap.js'
import { pushTypeScriptProperties } from './utilities/pushTypeScriptProperties.js'
import { sanitizePluginConfig } from './utilities/sanitizePluginConfig.js'
export const ecommercePlugin =
(pluginConfig?: EcommercePluginConfig) =>
async (incomingConfig: Config): Promise<Config> => {
if (!pluginConfig) {
return incomingConfig
}
const sanitizedPluginConfig = sanitizePluginConfig({ pluginConfig })
/**
* Used to keep track of the slugs of collections in case they are overridden by the user.
*/
const collectionSlugMap = getCollectionSlugMap({ sanitizedPluginConfig })
const accessConfig = sanitizedPluginConfig.access
// Ensure collections exists
if (!incomingConfig.collections) {
incomingConfig.collections = []
}
// Controls whether variants are enabled in the plugin. This is toggled to true under products config
let enableVariants = false
const currenciesConfig: Required<SanitizedEcommercePluginConfig['currencies']> =
sanitizedPluginConfig.currencies
let addressFields
if (sanitizedPluginConfig.addresses) {
addressFields = sanitizedPluginConfig.addresses.addressFields
const supportedCountries = sanitizedPluginConfig.addresses.supportedCountries
const defaultAddressesCollection = createAddressesCollection({
access: {
adminOrCustomerOwner: accessConfig.adminOrCustomerOwner,
authenticatedOnly: accessConfig.authenticatedOnly,
customerOnlyFieldAccess: accessConfig.customerOnlyFieldAccess,
},
addressFields,
customersSlug: collectionSlugMap.customers,
supportedCountries,
})
const addressesCollection =
sanitizedPluginConfig.addresses &&
typeof sanitizedPluginConfig.addresses === 'object' &&
'addressesCollectionOverride' in sanitizedPluginConfig.addresses &&
sanitizedPluginConfig.addresses.addressesCollectionOverride
? await sanitizedPluginConfig.addresses.addressesCollectionOverride({
defaultCollection: defaultAddressesCollection,
})
: defaultAddressesCollection
incomingConfig.collections.push(addressesCollection)
}
if (sanitizedPluginConfig.products) {
const productsConfig =
typeof sanitizedPluginConfig.products === 'boolean'
? {
variants: true,
}
: sanitizedPluginConfig.products
enableVariants = Boolean(productsConfig.variants)
if (productsConfig.variants) {
const variantsConfig =
typeof productsConfig.variants === 'boolean' ? undefined : productsConfig.variants
const defaultVariantsCollection = createVariantsCollection({
access: {
adminOnly: accessConfig.adminOnly,
adminOrPublishedStatus: accessConfig.adminOrPublishedStatus,
},
currenciesConfig,
inventory: sanitizedPluginConfig.inventory,
productsSlug: collectionSlugMap.products,
variantOptionsSlug: collectionSlugMap.variantOptions,
})
const variants =
variantsConfig &&
typeof variantsConfig === 'object' &&
'variantsCollectionOverride' in variantsConfig &&
variantsConfig.variantsCollectionOverride
? await variantsConfig.variantsCollectionOverride({
defaultCollection: defaultVariantsCollection,
})
: defaultVariantsCollection
const defaultVariantTypesCollection = createVariantTypesCollection({
access: {
adminOnly: accessConfig.adminOnly,
publicAccess: accessConfig.publicAccess,
},
variantOptionsSlug: collectionSlugMap.variantOptions,
})
const variantTypes =
variantsConfig &&
typeof variantsConfig === 'object' &&
'variantTypesCollectionOverride' in variantsConfig &&
variantsConfig.variantTypesCollectionOverride
? await variantsConfig.variantTypesCollectionOverride({
defaultCollection: defaultVariantTypesCollection,
})
: defaultVariantTypesCollection
const defaultVariantOptionsCollection = createVariantOptionsCollection({
access: {
adminOnly: accessConfig.adminOnly,
publicAccess: accessConfig.publicAccess,
},
variantTypesSlug: collectionSlugMap.variantTypes,
})
const variantOptions =
variantsConfig &&
typeof variantsConfig === 'object' &&
'variantOptionsCollectionOverride' in variantsConfig &&
variantsConfig.variantOptionsCollectionOverride
? await variantsConfig.variantOptionsCollectionOverride({
defaultCollection: defaultVariantOptionsCollection,
})
: defaultVariantOptionsCollection
incomingConfig.collections.push(variants, variantTypes, variantOptions)
}
const defaultProductsCollection = createProductsCollection({
access: {
adminOnly: accessConfig.adminOnly,
adminOrPublishedStatus: accessConfig.adminOrPublishedStatus,
},
currenciesConfig,
enableVariants,
inventory: sanitizedPluginConfig.inventory,
variantsSlug: collectionSlugMap.variants,
variantTypesSlug: collectionSlugMap.variantTypes,
})
const productsCollection =
productsConfig &&
'productsCollectionOverride' in productsConfig &&
productsConfig.productsCollectionOverride
? await productsConfig.productsCollectionOverride({
defaultCollection: defaultProductsCollection,
})
: defaultProductsCollection
incomingConfig.collections.push(productsCollection)
if (sanitizedPluginConfig.carts) {
const defaultCartsCollection = createCartsCollection({
access: {
adminOrCustomerOwner: accessConfig.adminOrCustomerOwner,
publicAccess: accessConfig.publicAccess,
},
currenciesConfig,
customersSlug: collectionSlugMap.customers,
enableVariants: Boolean(productsConfig.variants),
productsSlug: collectionSlugMap.products,
variantsSlug: collectionSlugMap.variants,
})
const cartsCollection =
sanitizedPluginConfig.carts &&
typeof sanitizedPluginConfig.carts === 'object' &&
'cartsCollectionOverride' in sanitizedPluginConfig.carts &&
sanitizedPluginConfig.carts.cartsCollectionOverride
? await sanitizedPluginConfig.carts.cartsCollectionOverride({
defaultCollection: defaultCartsCollection,
})
: defaultCartsCollection
incomingConfig.collections.push(cartsCollection)
}
}
if (sanitizedPluginConfig.orders) {
const defaultOrdersCollection = createOrdersCollection({
access: {
adminOnly: accessConfig.adminOnly,
adminOnlyFieldAccess: accessConfig.adminOnlyFieldAccess,
adminOrCustomerOwner: accessConfig.adminOrCustomerOwner,
},
addressFields,
currenciesConfig,
customersSlug: collectionSlugMap.customers,
enableVariants,
productsSlug: collectionSlugMap.products,
variantsSlug: collectionSlugMap.variants,
})
const ordersCollection =
sanitizedPluginConfig.orders &&
typeof sanitizedPluginConfig.orders === 'object' &&
'ordersCollectionOverride' in sanitizedPluginConfig.orders &&
sanitizedPluginConfig.orders.ordersCollectionOverride
? await sanitizedPluginConfig.orders.ordersCollectionOverride({
defaultCollection: defaultOrdersCollection,
})
: defaultOrdersCollection
incomingConfig.collections.push(ordersCollection)
}
const paymentMethods = sanitizedPluginConfig.payments.paymentMethods
if (sanitizedPluginConfig.payments) {
if (paymentMethods.length) {
if (!Array.isArray(incomingConfig.endpoints)) {
incomingConfig.endpoints = []
}
const productsValidation =
(typeof sanitizedPluginConfig.products === 'object' &&
sanitizedPluginConfig.products.validation) ||
undefined
paymentMethods.forEach((paymentMethod) => {
const methodPath = `/payments/${paymentMethod.name}`
const endpoints: Endpoint[] = []
const initiatePayment: Endpoint = {
handler: initiatePaymentHandler({
currenciesConfig,
inventory: sanitizedPluginConfig.inventory,
paymentMethod,
productsSlug: collectionSlugMap.products,
productsValidation,
transactionsSlug: collectionSlugMap.transactions,
variantsSlug: collectionSlugMap.variants,
}),
method: 'post',
path: `${methodPath}/initiate`,
}
const confirmOrder: Endpoint = {
handler: confirmOrderHandler({
cartsSlug: collectionSlugMap.carts,
currenciesConfig,
ordersSlug: collectionSlugMap.orders,
paymentMethod,
productsValidation,
transactionsSlug: collectionSlugMap.transactions,
}),
method: 'post',
path: `${methodPath}/confirm-order`,
}
endpoints.push(initiatePayment, confirmOrder)
// Attach any additional endpoints defined in the payment method
if (paymentMethod.endpoints && paymentMethod.endpoints.length > 0) {
const methodEndpoints = paymentMethod.endpoints.map((endpoint) => {
const path = endpoint.path.startsWith('/') ? endpoint.path : `/${endpoint.path}`
return {
...endpoint,
path: `${methodPath}${path}`,
}
})
endpoints.push(...methodEndpoints)
}
incomingConfig.endpoints!.push(...endpoints)
})
}
}
if (sanitizedPluginConfig.transactions) {
const defaultTransactionsCollection = createTransactionsCollection({
access: {
adminOnly: accessConfig.adminOnly,
},
addressFields,
cartsSlug: collectionSlugMap.carts,
currenciesConfig,
customersSlug: collectionSlugMap.customers,
enableVariants,
ordersSlug: collectionSlugMap.orders,
paymentMethods,
productsSlug: collectionSlugMap.products,
variantsSlug: collectionSlugMap.variants,
})
const transactionsCollection =
sanitizedPluginConfig.transactions &&
typeof sanitizedPluginConfig.transactions === 'object' &&
'transactionsCollectionOverride' in sanitizedPluginConfig.transactions &&
sanitizedPluginConfig.transactions.transactionsCollectionOverride
? await sanitizedPluginConfig.transactions.transactionsCollectionOverride({
defaultCollection: defaultTransactionsCollection,
})
: defaultTransactionsCollection
incomingConfig.collections.push(transactionsCollection)
}
if (!incomingConfig.i18n) {
incomingConfig.i18n = {}
}
if (!incomingConfig.i18n?.translations) {
incomingConfig.i18n.translations = {}
}
incomingConfig.i18n.translations = deepMergeSimple(
translations,
incomingConfig.i18n?.translations,
)
if (!incomingConfig.typescript) {
incomingConfig.typescript = {}
}
if (!incomingConfig.typescript.schema) {
incomingConfig.typescript.schema = []
}
incomingConfig.typescript.schema.push((args) =>
pushTypeScriptProperties({
...args,
collectionSlugMap,
sanitizedPluginConfig,
}),
)
return incomingConfig
}
export {
createAddressesCollection,
createCartsCollection,
createOrdersCollection,
createProductsCollection,
createTransactionsCollection,
createVariantOptionsCollection,
createVariantsCollection,
createVariantTypesCollection,
}
export { EUR, GBP, USD } from './currencies/index.js'
export { amountField } from './fields/amountField.js'
export { currencyField } from './fields/currencyField.js'
export { pricesField } from './fields/pricesField.js'
export { statusField } from './fields/statusField.js'
export { variantsFields } from './fields/variantsFields.js'

View File

@@ -0,0 +1,132 @@
import Stripe from 'stripe'
import type { PaymentAdapter } from '../../../types/index.js'
import type { StripeAdapterArgs } from './index.js'
type Props = {
apiVersion?: Stripe.StripeConfig['apiVersion']
appInfo?: Stripe.StripeConfig['appInfo']
secretKey: StripeAdapterArgs['secretKey']
}
export const confirmOrder: (props: Props) => NonNullable<PaymentAdapter>['confirmOrder'] =
(props) =>
async ({ data, ordersSlug = 'orders', req, transactionsSlug = 'transactions' }) => {
const payload = req.payload
const { apiVersion, appInfo, secretKey } = props || {}
const customerEmail = data.customerEmail
const paymentIntentID = data.paymentIntentID as string
if (!secretKey) {
throw new Error('Stripe secret key is required')
}
if (!paymentIntentID) {
throw new Error('PaymentIntent ID is required')
}
const stripe = new Stripe(secretKey, {
// API version can only be the latest, stripe recommends ts ignoring it
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - ignoring since possible versions are not type safe, only the latest version is recognised
apiVersion: apiVersion || '2025-03-31.basil',
appInfo: appInfo || {
name: 'Stripe Payload Plugin',
url: 'https://payloadcms.com',
},
})
try {
let customer = (
await stripe.customers.list({
email: customerEmail,
})
).data[0]
if (!customer?.id) {
customer = await stripe.customers.create({
email: customerEmail,
})
}
// Find our existing transaction by the payment intent ID
const transactionsResults = await payload.find({
collection: transactionsSlug,
where: {
'stripe.paymentIntentID': {
equals: paymentIntentID,
},
},
})
const transaction = transactionsResults.docs[0]
if (!transactionsResults.totalDocs || !transaction) {
throw new Error('No transaction found for the provided PaymentIntent ID')
}
// Verify the payment intent exists and retrieve it
const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentID)
const cartID = paymentIntent.metadata.cartID
const cartItemsSnapshot = paymentIntent.metadata.cartItemsSnapshot
? JSON.parse(paymentIntent.metadata.cartItemsSnapshot)
: undefined
const shippingAddress = paymentIntent.metadata.shippingAddress
? JSON.parse(paymentIntent.metadata.shippingAddress)
: undefined
if (!cartID) {
throw new Error('Cart ID not found in the PaymentIntent metadata')
}
if (!cartItemsSnapshot || !Array.isArray(cartItemsSnapshot)) {
throw new Error('Cart items snapshot not found or invalid in the PaymentIntent metadata')
}
const order = await payload.create({
collection: ordersSlug,
data: {
amount: paymentIntent.amount,
currency: paymentIntent.currency.toUpperCase(),
...(req.user ? { customer: req.user.id } : { customerEmail }),
items: cartItemsSnapshot,
shippingAddress,
status: 'processing',
transactions: [transaction.id],
},
})
const timestamp = new Date().toISOString()
await payload.update({
id: cartID,
collection: 'carts',
data: {
purchasedAt: timestamp,
},
})
await payload.update({
id: transaction.id,
collection: transactionsSlug,
data: {
order: order.id,
status: 'succeeded',
},
})
return {
message: 'Payment initiated successfully',
orderID: order.id,
transactionID: transaction.id,
}
} catch (error) {
payload.logger.error(error, 'Error initiating payment with Stripe')
throw new Error(error instanceof Error ? error.message : 'Unknown error initiating payment')
}
}

View File

@@ -0,0 +1,69 @@
import type { Endpoint } from 'payload'
import Stripe from 'stripe'
import type { StripeAdapterArgs } from '../index.js'
type Props = {
apiVersion?: Stripe.StripeConfig['apiVersion']
appInfo?: Stripe.StripeConfig['appInfo']
secretKey: StripeAdapterArgs['secretKey']
webhooks?: StripeAdapterArgs['webhooks']
webhookSecret: StripeAdapterArgs['webhookSecret']
}
export const webhooksEndpoint: (props: Props) => Endpoint = (props) => {
const { apiVersion, appInfo, secretKey, webhooks, webhookSecret } = props || {}
const handler: Endpoint['handler'] = async (req) => {
let returnStatus = 200
if (webhookSecret && secretKey && req.text) {
const stripe = new Stripe(secretKey, {
// API version can only be the latest, stripe recommends ts ignoring it
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - ignoring since possible versions are not type safe, only the latest version is recognised
apiVersion: apiVersion || '2025-03-31.basil',
appInfo: appInfo || {
name: 'Stripe Payload Plugin',
url: 'https://payloadcms.com',
},
})
const body = await req.text()
const stripeSignature = req.headers.get('stripe-signature')
if (stripeSignature) {
let event: Stripe.Event | undefined
try {
event = stripe.webhooks.constructEvent(body, stripeSignature, webhookSecret)
} catch (err: unknown) {
const msg: string = err instanceof Error ? err.message : JSON.stringify(err)
req.payload.logger.error(`Error constructing Stripe event: ${msg}`)
returnStatus = 400
}
if (typeof webhooks === 'object' && event) {
const webhookEventHandler = webhooks[event.type]
if (typeof webhookEventHandler === 'function') {
await webhookEventHandler({
event,
req,
stripe,
})
}
}
}
}
return Response.json({ received: true }, { status: returnStatus })
}
return {
handler,
method: 'post',
path: '/webhooks',
}
}

View File

@@ -0,0 +1,135 @@
import type { Field, GroupField, PayloadRequest } from 'payload'
import type { Stripe } from 'stripe'
import type {
PaymentAdapter,
PaymentAdapterArgs,
PaymentAdapterClient,
PaymentAdapterClientArgs,
} from '../../../types/index.js'
import { confirmOrder } from './confirmOrder.js'
import { webhooksEndpoint } from './endpoints/webhooks.js'
import { initiatePayment } from './initiatePayment.js'
type StripeWebhookHandler = (args: {
event: Stripe.Event
req: PayloadRequest
stripe: Stripe
}) => Promise<void> | void
type StripeWebhookHandlers = {
/**
* Description of the event (e.g., invoice.created or charge.refunded).
*/
[webhookName: string]: StripeWebhookHandler
}
export type StripeAdapterArgs = {
/**
* This library's types only reflect the latest API version.
*
* We recommend upgrading your account's API Version to the latest version
* if you wish to use TypeScript with this library.
*
* If you wish to remain on your account's default API version,
* you may pass `null` or another version instead of the latest version,
* and add a `@ts-ignore` comment here and anywhere the types differ between API versions.
*
* @docs https://stripe.com/docs/api/versioning
*/
apiVersion?: Stripe.StripeConfig['apiVersion']
appInfo?: Stripe.StripeConfig['appInfo']
publishableKey: string
secretKey: string
webhooks?: StripeWebhookHandlers
webhookSecret?: string
} & PaymentAdapterArgs
export const stripeAdapter: (props: StripeAdapterArgs) => PaymentAdapter = (props) => {
const { apiVersion, appInfo, groupOverrides, secretKey, webhooks, webhookSecret } = props
const label = props?.label || 'Stripe'
const baseFields: Field[] = [
{
name: 'customerID',
type: 'text',
label: 'Stripe Customer ID',
},
{
name: 'paymentIntentID',
type: 'text',
label: 'Stripe PaymentIntent ID',
},
]
const groupField: GroupField = {
name: 'stripe',
type: 'group',
...groupOverrides,
admin: {
condition: (data) => {
const path = 'paymentMethod'
return data?.[path] === 'stripe'
},
...groupOverrides?.admin,
},
fields:
groupOverrides?.fields && typeof groupOverrides?.fields === 'function'
? groupOverrides.fields({ defaultFields: baseFields })
: baseFields,
}
return {
name: 'stripe',
confirmOrder: confirmOrder({
apiVersion,
appInfo,
secretKey,
}),
endpoints: [webhooksEndpoint({ apiVersion, appInfo, secretKey, webhooks, webhookSecret })],
group: groupField,
initiatePayment: initiatePayment({
apiVersion,
appInfo,
secretKey,
}),
label,
}
}
export type StripeAdapterClientArgs = {
/**
* This library's types only reflect the latest API version.
*
* We recommend upgrading your account's API Version to the latest version
* if you wish to use TypeScript with this library.
*
* If you wish to remain on your account's default API version,
* you may pass `null` or another version instead of the latest version,
* and add a `@ts-ignore` comment here and anywhere the types differ between API versions.
*
* @docs https://stripe.com/docs/api/versioning
*/
apiVersion?: Stripe.StripeConfig['apiVersion']
appInfo?: Stripe.StripeConfig['appInfo']
publishableKey: string
} & PaymentAdapterClientArgs
export const stripeAdapterClient: (props: StripeAdapterClientArgs) => PaymentAdapterClient = (
props,
) => {
return {
name: 'stripe',
confirmOrder: true,
initiatePayment: true,
label: 'Card',
}
}
export type InitiatePaymentReturnType = {
clientSecret: string
message: string
paymentIntentID: string
}

View File

@@ -0,0 +1,131 @@
import Stripe from 'stripe'
import type { PaymentAdapter } from '../../../types/index.js'
import type { InitiatePaymentReturnType, StripeAdapterArgs } from './index.js'
type Props = {
apiVersion?: Stripe.StripeConfig['apiVersion']
appInfo?: Stripe.StripeConfig['appInfo']
secretKey: StripeAdapterArgs['secretKey']
}
export const initiatePayment: (props: Props) => NonNullable<PaymentAdapter>['initiatePayment'] =
(props) =>
async ({ data, req, transactionsSlug }) => {
const payload = req.payload
const { apiVersion, appInfo, secretKey } = props || {}
const customerEmail = data.customerEmail
const currency = data.currency
const cart = data.cart
const amount = cart.subtotal
const billingAddressFromData = data.billingAddress
const shippingAddressFromData = data.shippingAddress
if (!secretKey) {
throw new Error('Stripe secret key is required.')
}
if (!currency) {
throw new Error('Currency is required.')
}
if (!cart || !cart.items || cart.items.length === 0) {
throw new Error('Cart is empty or not provided.')
}
if (!customerEmail || typeof customerEmail !== 'string') {
throw new Error('A valid customer email is required to make a purchase.')
}
if (!amount || typeof amount !== 'number' || amount <= 0) {
throw new Error('A valid amount is required to initiate a payment.')
}
const stripe = new Stripe(secretKey, {
// API version can only be the latest, stripe recommends ts ignoring it
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - ignoring since possible versions are not type safe, only the latest version is recognised
apiVersion: apiVersion || '2025-06-30.preview',
appInfo: appInfo || {
name: 'Stripe Payload Plugin',
url: 'https://payloadcms.com',
},
})
try {
let customer = (
await stripe.customers.list({
email: customerEmail,
})
).data[0]
if (!customer?.id) {
customer = await stripe.customers.create({
email: customerEmail,
})
}
const flattenedCart = cart.items.map((item) => {
const productID = typeof item.product === 'object' ? item.product.id : item.product
const variantID = item.variant
? typeof item.variant === 'object'
? item.variant.id
: item.variant
: undefined
return {
product: productID,
quantity: item.quantity,
variant: variantID,
}
})
const shippingAddressAsString = JSON.stringify(shippingAddressFromData)
const paymentIntent = await stripe.paymentIntents.create({
amount,
automatic_payment_methods: {
enabled: true,
},
currency,
customer: customer.id,
metadata: {
cartID: cart.id,
cartItemsSnapshot: JSON.stringify(flattenedCart),
shippingAddress: shippingAddressAsString,
},
})
// Create a transaction for the payment intent in the database
const transaction = await payload.create({
collection: transactionsSlug,
data: {
...(req.user ? { customer: req.user.id } : { customerEmail }),
amount: paymentIntent.amount,
billingAddress: billingAddressFromData,
cart: cart.id,
currency: paymentIntent.currency.toUpperCase(),
items: flattenedCart,
paymentMethod: 'stripe',
status: 'pending',
stripe: {
customerID: customer.id,
paymentIntentID: paymentIntent.id,
},
},
})
const returnData: InitiatePaymentReturnType = {
clientSecret: paymentIntent.client_secret || '',
message: 'Payment initiated successfully',
paymentIntentID: paymentIntent.id,
}
return returnData
} catch (error) {
payload.logger.error(error, 'Error initiating payment with Stripe')
throw new Error(error instanceof Error ? error.message : 'Unknown error initiating payment')
}
}

View File

@@ -0,0 +1,894 @@
'use client'
import type { DefaultDocumentIDType, TypedUser } from 'payload'
import { deepMergeSimple } from 'payload/shared'
import * as qs from 'qs-esm'
import React, { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
AddressesCollection,
CartItem,
CartsCollection,
ContextProps,
Currency,
EcommerceContextType,
} from '../../types/index.js'
const defaultContext: EcommerceContextType = {
addItem: async () => {},
clearCart: async () => {},
confirmOrder: async () => {},
createAddress: async () => {},
currenciesConfig: {
defaultCurrency: 'USD',
supportedCurrencies: [
{
code: 'USD',
decimals: 2,
label: 'US Dollar',
symbol: '$',
},
],
},
currency: {
code: 'USD',
decimals: 2,
label: 'US Dollar',
symbol: '$',
},
decrementItem: async () => {},
incrementItem: async () => {},
initiatePayment: async () => {},
paymentMethods: [],
removeItem: async () => {},
setCurrency: () => {},
updateAddress: async () => {},
}
const EcommerceContext = createContext<EcommerceContextType>(defaultContext)
const defaultLocalStorage = {
key: 'cart',
}
export const EcommerceProvider: React.FC<ContextProps> = ({
addressesSlug = 'addresses',
api,
cartsSlug = 'carts',
children,
currenciesConfig = {
defaultCurrency: 'USD',
supportedCurrencies: [
{
code: 'USD',
decimals: 2,
label: 'US Dollar',
symbol: '$',
},
],
},
customersSlug = 'users',
debug = false,
paymentMethods = [],
syncLocalStorage = true,
}) => {
const localStorageConfig =
syncLocalStorage && typeof syncLocalStorage === 'object'
? {
...defaultLocalStorage,
...syncLocalStorage,
}
: defaultLocalStorage
const { apiRoute = '/api', cartsFetchQuery = {}, serverURL = '' } = api || {}
const baseAPIURL = `${serverURL}${apiRoute}`
const [user, setUser] = useState<null | TypedUser>(null)
const [addresses, setAddresses] = useState<AddressesCollection[]>()
const hasRendered = useRef(false)
/**
* The ID of the cart associated with the current session.
* This is used to identify the cart in the database or local storage.
* It can be null if no cart has been created yet.
*/
const [cartID, setCartID] = useState<DefaultDocumentIDType>()
const [cart, setCart] = useState<CartsCollection>()
const [selectedCurrency, setSelectedCurrency] = useState<Currency>(
() =>
currenciesConfig.supportedCurrencies.find(
(c) => c.code === currenciesConfig.defaultCurrency,
)!,
)
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<null | string>(null)
const cartQuery = useMemo(() => {
const priceField = `priceIn${selectedCurrency.code}`
const baseQuery = {
depth: 0,
populate: {
products: {
[priceField]: true,
},
variants: {
options: true,
[priceField]: true,
},
},
select: {
items: true,
subtotal: true,
},
}
return deepMergeSimple(baseQuery, cartsFetchQuery)
}, [selectedCurrency.code, cartsFetchQuery])
const createCart = useCallback(
async (initialData: Record<string, unknown>) => {
const query = qs.stringify(cartQuery)
const response = await fetch(`${baseAPIURL}/${cartsSlug}?${query}`, {
body: JSON.stringify({
...initialData,
currency: selectedCurrency.code,
customer: user?.id,
}),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to create cart: ${errorText}`)
}
const data = await response.json()
if (data.error) {
throw new Error(`Cart creation error: ${data.error}`)
}
return data.doc as CartsCollection
},
[baseAPIURL, cartQuery, cartsSlug, selectedCurrency.code, user?.id],
)
const getCart = useCallback(
async (cartID: DefaultDocumentIDType) => {
const query = qs.stringify(cartQuery)
const response = await fetch(`${baseAPIURL}/${cartsSlug}/${cartID}?${query}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'GET',
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to fetch cart: ${errorText}`)
}
const data = await response.json()
if (data.error) {
throw new Error(`Cart fetch error: ${data.error}`)
}
return data as CartsCollection
},
[baseAPIURL, cartQuery, cartsSlug],
)
const updateCart = useCallback(
async (cartID: DefaultDocumentIDType, data: Partial<CartsCollection>) => {
const query = qs.stringify(cartQuery)
const response = await fetch(`${baseAPIURL}/${cartsSlug}/${cartID}?${query}`, {
body: JSON.stringify(data),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'PATCH',
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to update cart: ${errorText}`)
}
const updatedCart = await response.json()
setCart(updatedCart.doc as CartsCollection)
},
[baseAPIURL, cartQuery, cartsSlug],
)
const deleteCart = useCallback(
async (cartID: DefaultDocumentIDType) => {
const response = await fetch(`${baseAPIURL}/${cartsSlug}/${cartID}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'DELETE',
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to update cart: ${errorText}`)
}
setCart(undefined)
setCartID(undefined)
},
[baseAPIURL, cartsSlug],
)
useEffect(() => {
if (hasRendered.current) {
if (syncLocalStorage && cartID) {
localStorage.setItem(localStorageConfig.key, cartID as string)
}
}
}, [cartID, localStorageConfig.key, syncLocalStorage])
const addItem: EcommerceContextType['addItem'] = useCallback(
async (item, quantity = 1) => {
if (cartID) {
const existingCart = await getCart(cartID)
if (!existingCart) {
// console.error(`Cart with ID "${cartID}" not found`)
setCartID(undefined)
setCart(undefined)
return
}
// Check if the item already exists in the cart
const existingItemIndex =
existingCart.items?.findIndex((cartItem: CartItem) => {
const productID =
typeof cartItem.product === 'object' ? cartItem.product.id : item.product
const variantID =
cartItem.variant && typeof cartItem.variant === 'object'
? cartItem.variant.id
: item.variant
return (
productID === item.product &&
(item.variant && variantID ? variantID === item.variant : true)
)
}) ?? -1
let updatedItems = existingCart.items ? [...existingCart.items] : []
if (existingItemIndex !== -1) {
// If the item exists, update its quantity
updatedItems[existingItemIndex].quantity =
updatedItems[existingItemIndex].quantity + quantity
// Update the cart with the new items
await updateCart(cartID, {
items: updatedItems,
})
} else {
// If the item does not exist, add it to the cart
updatedItems = [...(existingCart.items ?? []), { ...item, quantity }]
}
// Update the cart with the new items
await updateCart(cartID, {
items: updatedItems,
})
} else {
// If no cartID exists, create a new cart
const newCart = await createCart({ items: [{ ...item, quantity }] })
setCartID(newCart.id)
setCart(newCart)
}
},
[cartID, createCart, getCart, updateCart],
)
const removeItem: EcommerceContextType['removeItem'] = useCallback(
async (targetID) => {
if (!cartID) {
return
}
const existingCart = await getCart(cartID)
if (!existingCart) {
// console.error(`Cart with ID "${cartID}" not found`)
setCartID(undefined)
setCart(undefined)
return
}
// Check if the item already exists in the cart
const existingItemIndex =
existingCart.items?.findIndex((cartItem: CartItem) => cartItem.id === targetID) ?? -1
if (existingItemIndex !== -1) {
// If the item exists, remove it from the cart
const updatedItems = existingCart.items ? [...existingCart.items] : []
updatedItems.splice(existingItemIndex, 1)
// Update the cart with the new items
await updateCart(cartID, {
items: updatedItems,
})
}
},
[cartID, getCart, updateCart],
)
const incrementItem: EcommerceContextType['incrementItem'] = useCallback(
async (targetID) => {
if (!cartID) {
return
}
const existingCart = await getCart(cartID)
if (!existingCart) {
// console.error(`Cart with ID "${cartID}" not found`)
setCartID(undefined)
setCart(undefined)
return
}
// Check if the item already exists in the cart
const existingItemIndex =
existingCart.items?.findIndex((cartItem: CartItem) => cartItem.id === targetID) ?? -1
let updatedItems = existingCart.items ? [...existingCart.items] : []
if (existingItemIndex !== -1) {
// If the item exists, increment its quantity
updatedItems[existingItemIndex].quantity = updatedItems[existingItemIndex].quantity + 1 // Increment by 1
// Update the cart with the new items
await updateCart(cartID, {
items: updatedItems,
})
} else {
// If the item does not exist, add it to the cart with quantity 1
updatedItems = [...(existingCart.items ?? []), { product: targetID, quantity: 1 }]
// Update the cart with the new items
await updateCart(cartID, {
items: updatedItems,
})
}
},
[cartID, getCart, updateCart],
)
const decrementItem: EcommerceContextType['decrementItem'] = useCallback(
async (targetID) => {
if (!cartID) {
return
}
const existingCart = await getCart(cartID)
if (!existingCart) {
// console.error(`Cart with ID "${cartID}" not found`)
setCartID(undefined)
setCart(undefined)
return
}
// Check if the item already exists in the cart
const existingItemIndex =
existingCart.items?.findIndex((cartItem: CartItem) => cartItem.id === targetID) ?? -1
const updatedItems = existingCart.items ? [...existingCart.items] : []
if (existingItemIndex !== -1) {
// If the item exists, decrement its quantity
updatedItems[existingItemIndex].quantity = updatedItems[existingItemIndex].quantity - 1 // Decrement by 1
// If the quantity reaches 0, remove the item from the cart
if (updatedItems[existingItemIndex].quantity <= 0) {
updatedItems.splice(existingItemIndex, 1)
}
// Update the cart with the new items
await updateCart(cartID, {
items: updatedItems,
})
}
},
[cartID, getCart, updateCart],
)
const clearCart: EcommerceContextType['clearCart'] = useCallback(async () => {
if (cartID) {
await deleteCart(cartID)
}
}, [cartID, deleteCart])
const setCurrency: EcommerceContextType['setCurrency'] = useCallback(
(currency) => {
if (selectedCurrency.code === currency) {
return
}
const foundCurrency = currenciesConfig.supportedCurrencies.find((c) => c.code === currency)
if (!foundCurrency) {
throw new Error(`Currency with code "${currency}" not found in config`)
}
setSelectedCurrency(foundCurrency)
},
[currenciesConfig.supportedCurrencies, selectedCurrency.code],
)
const initiatePayment = useCallback<EcommerceContextType['initiatePayment']>(
async (paymentMethodID, options) => {
const paymentMethod = paymentMethods.find((method) => method.name === paymentMethodID)
if (!paymentMethod) {
throw new Error(`Payment method with ID "${paymentMethodID}" not found`)
}
if (!cartID) {
throw new Error(`No cart is provided.`)
}
setSelectedPaymentMethod(paymentMethodID)
if (paymentMethod.initiatePayment) {
const fetchURL = `${baseAPIURL}/payments/${paymentMethodID}/initiate`
const data = {
cartID,
currency: selectedCurrency.code,
}
try {
const response = await fetch(fetchURL, {
body: JSON.stringify({
...data,
...(options?.additionalData || {}),
}),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
if (!response.ok) {
const responseError = await response.text()
throw new Error(responseError)
}
const responseData = await response.json()
if (responseData.error) {
throw new Error(responseData.error)
}
return responseData
} catch (error) {
if (debug) {
// eslint-disable-next-line no-console
console.error('Error initiating payment:', error)
}
throw new Error(error instanceof Error ? error.message : 'Failed to initiate payment')
}
} else {
throw new Error(`Payment method "${paymentMethodID}" does not support payment initiation`)
}
},
[baseAPIURL, cartID, debug, paymentMethods, selectedCurrency.code],
)
const confirmOrder = useCallback<EcommerceContextType['initiatePayment']>(
async (paymentMethodID, options) => {
if (!cartID) {
throw new Error(`Cart is empty.`)
}
const paymentMethod = paymentMethods.find((pm) => pm.name === paymentMethodID)
if (!paymentMethod) {
throw new Error(`Payment method with ID "${paymentMethodID}" not found`)
}
if (paymentMethod.confirmOrder) {
const fetchURL = `${baseAPIURL}/payments/${paymentMethodID}/confirm-order`
const data = {
cartID,
currency: selectedCurrency.code,
}
const response = await fetch(fetchURL, {
body: JSON.stringify({
...data,
...(options?.additionalData || {}),
}),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
if (!response.ok) {
const responseError = await response.text()
throw new Error(responseError)
}
const responseData = await response.json()
if (responseData.error) {
throw new Error(responseData.error)
}
return responseData
} else {
throw new Error(`Payment method "${paymentMethodID}" does not support order confirmation`)
}
},
[baseAPIURL, cartID, paymentMethods, selectedCurrency.code],
)
const getUser = useCallback(async () => {
try {
const query = qs.stringify({
depth: 0,
select: {
id: true,
carts: true,
},
})
const response = await fetch(`${baseAPIURL}/${customersSlug}/me?${query}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'GET',
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to fetch user: ${errorText}`)
}
const userData = await response.json()
if (userData.error) {
throw new Error(`User fetch error: ${userData.error}`)
}
if (userData.user) {
setUser(userData.user as TypedUser)
return userData.user as TypedUser
}
} catch (error) {
if (debug) {
// eslint-disable-next-line no-console
console.error('Error fetching user:', error)
}
setUser(null)
throw new Error(
`Failed to fetch user: ${error instanceof Error ? error.message : 'Unknown error'}`,
)
}
}, [baseAPIURL, customersSlug, debug])
const getAddresses = useCallback(async () => {
if (!user) {
return
}
try {
const query = qs.stringify({
depth: 0,
limit: 0,
pagination: false,
})
const response = await fetch(`${baseAPIURL}/${addressesSlug}?${query}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'GET',
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(errorText)
}
const data = await response.json()
if (data.error) {
throw new Error(`Address fetch error: ${data.error}`)
}
if (data.docs && data.docs.length > 0) {
setAddresses(data.docs)
}
} catch (error) {
if (debug) {
// eslint-disable-next-line no-console
console.error('Error fetching addresses:', error)
}
setAddresses(undefined)
throw new Error(
`Failed to fetch addresses: ${error instanceof Error ? error.message : 'Unknown error'}`,
)
}
}, [user, baseAPIURL, addressesSlug, debug])
const updateAddress = useCallback<EcommerceContextType['updateAddress']>(
async (addressID, address) => {
if (!user) {
throw new Error('User must be logged in to update or create an address')
}
try {
const response = await fetch(`${baseAPIURL}/${addressesSlug}/${addressID}`, {
body: JSON.stringify(address),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'PATCH',
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to update or create address: ${errorText}`)
}
const data = await response.json()
if (data.error) {
throw new Error(`Address update/create error: ${data.error}`)
}
// Refresh addresses after updating or creating
await getAddresses()
} catch (error) {
if (debug) {
// eslint-disable-next-line no-console
console.error('Error updating or creating address:', error)
}
throw new Error(
`Failed to update or create address: ${error instanceof Error ? error.message : 'Unknown error'}`,
)
}
},
[user, baseAPIURL, addressesSlug, getAddresses, debug],
)
const createAddress = useCallback<EcommerceContextType['createAddress']>(
async (address) => {
if (!user) {
throw new Error('User must be logged in to update or create an address')
}
try {
const response = await fetch(`${baseAPIURL}/${addressesSlug}`, {
body: JSON.stringify(address),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to update or create address: ${errorText}`)
}
const data = await response.json()
if (data.error) {
throw new Error(`Address update/create error: ${data.error}`)
}
// Refresh addresses after updating or creating
await getAddresses()
} catch (error) {
if (debug) {
// eslint-disable-next-line no-console
console.error('Error updating or creating address:', error)
}
throw new Error(
`Failed to update or create address: ${error instanceof Error ? error.message : 'Unknown error'}`,
)
}
},
[user, baseAPIURL, addressesSlug, getAddresses, debug],
)
// If localStorage is enabled, we can add logic to persist the cart state
useEffect(() => {
if (!hasRendered.current) {
if (syncLocalStorage) {
const storedCart = localStorage.getItem(localStorageConfig.key)
if (storedCart) {
getCart(storedCart)
.then((fetchedCart) => {
setCart(fetchedCart)
setCartID(storedCart as DefaultDocumentIDType)
})
.catch((_) => {
// console.error('Error fetching cart from localStorage:', error)
// If there's an error fetching the cart, we can clear it from localStorage
localStorage.removeItem(localStorageConfig.key)
setCartID(undefined)
setCart(undefined)
})
}
}
hasRendered.current = true
void getUser().then((user) => {
if (user && user.cart?.docs && user.cart.docs.length > 0) {
// If the user has carts, we can set the cartID to the first cart
const cartID =
typeof user.cart.docs[0] === 'object' ? user.cart.docs[0].id : user.cart.docs[0]
if (cartID) {
getCart(cartID)
.then((fetchedCart) => {
setCart(fetchedCart)
setCartID(cartID)
})
.catch((error) => {
if (debug) {
// eslint-disable-next-line no-console
console.error('Error fetching user cart:', error)
}
setCart(undefined)
setCartID(undefined)
throw new Error(`Failed to fetch user cart: ${error.message}`)
})
}
}
})
}
}, [debug, getAddresses, getCart, getUser, localStorageConfig.key, syncLocalStorage])
useEffect(() => {
if (user) {
// If the user is logged in, fetch their addresses
void getAddresses()
} else {
// If no user is logged in, clear addresses
setAddresses(undefined)
}
}, [getAddresses, user])
return (
<EcommerceContext
value={{
addItem,
addresses,
cart,
clearCart,
confirmOrder,
createAddress,
currenciesConfig,
currency: selectedCurrency,
decrementItem,
incrementItem,
initiatePayment,
paymentMethods,
removeItem,
selectedPaymentMethod,
setCurrency,
updateAddress,
}}
>
{children}
</EcommerceContext>
)
}
export const useEcommerce = () => {
const context = use(EcommerceContext)
if (!context) {
throw new Error('useEcommerce must be used within an EcommerceProvider')
}
return context
}
export const useCurrency = () => {
const { currenciesConfig, currency, setCurrency } = useEcommerce()
const formatCurrency = useCallback(
(value?: null | number, options?: { currency?: Currency }): string => {
if (value === undefined || value === null) {
return ''
}
const currencyToUse = options?.currency || currency
if (!currencyToUse) {
return value.toString()
}
if (value === 0) {
return `${currencyToUse.symbol}0.${'0'.repeat(currencyToUse.decimals)}`
}
// Convert from base value (e.g., cents) to decimal value (e.g., dollars)
const decimalValue = value / Math.pow(10, currencyToUse.decimals)
// Format with the correct number of decimal places
return `${currencyToUse.symbol}${decimalValue.toFixed(currencyToUse.decimals)}`
},
[currency],
)
if (!currency) {
throw new Error('useCurrency must be used within an EcommerceProvider')
}
return {
currency,
formatCurrency,
setCurrency,
supportedCurrencies: currenciesConfig.supportedCurrencies,
}
}
export function useCart<T extends CartsCollection>() {
const { addItem, cart, clearCart, decrementItem, incrementItem, removeItem } = useEcommerce()
if (!addItem) {
throw new Error('useCart must be used within an EcommerceProvider')
}
return { addItem, cart: cart as T, clearCart, decrementItem, incrementItem, removeItem }
}
export const usePayments = () => {
const { confirmOrder, initiatePayment, paymentMethods, selectedPaymentMethod } = useEcommerce()
if (!initiatePayment) {
throw new Error('usePayments must be used within an EcommerceProvider')
}
return { confirmOrder, initiatePayment, paymentMethods, selectedPaymentMethod }
}
export function useAddresses<T extends AddressesCollection>() {
const { addresses, createAddress, updateAddress } = useEcommerce()
if (!createAddress) {
throw new Error('usePayments must be used within an EcommerceProvider')
}
return { addresses: addresses as T[], createAddress, updateAddress }
}

View File

@@ -0,0 +1,22 @@
import type { Currency } from '../../types/index.js'
/**
* Convert base value to display value with decimal point (e.g., 2500 to $25.00)
*/
export const convertFromBaseValue = ({
baseValue,
currency,
}: {
baseValue: number
currency: Currency
}): string => {
if (!currency) {
return baseValue.toString()
}
// Convert from base value (e.g., cents) to decimal value (e.g., dollars)
const decimalValue = baseValue / Math.pow(10, currency.decimals)
// Format with the correct number of decimal places
return decimalValue.toFixed(currency.decimals)
}

View File

@@ -0,0 +1,96 @@
import type { GenericTranslationsObject } from '@payloadcms/translations'
export const en: GenericTranslationsObject = {
$schema: './translation-schema.json',
'plugin-ecommerce': {
abandoned: 'Abandoned',
active: 'Active',
address: 'Address',
addressCity: 'City',
addressCompany: 'Company',
addressCountry: 'Country',
addresses: 'Addresses',
addressesCollectionDescription:
'Addresses are associated with customers are used to prefill shipping and billing when placing orders.',
addressFirstName: 'First name',
addressLastName: 'Last name',
addressLine1: 'Address 1',
addressLine2: 'Address 2',
addressPhone: 'Phone',
addressPostalCode: 'Postal code',
addressState: 'State',
addressTitle: 'Title',
amount: 'Amount',
availableVariants: 'Available variants',
billing: 'Billing',
billingAddress: 'Billing address',
cancelled: 'Cancelled',
cart: 'Cart',
carts: 'Carts',
cartsCollectionDescription:
"Carts represent a customer's selection of products they intend to purchase. They are related to a customer where possible and guest users do not have a customer attached.",
completed: 'Completed',
currency: 'Currency',
currencyNotSet: 'Currency not set.',
customer: 'Customer',
customerEmail: 'Customer email',
customers: 'Customers',
enableCurrencyPrice: `Enable {{currency}} price`,
enableVariants: 'Enable variants',
expired: 'Expired',
failed: 'Failed',
inventory: 'Inventory',
item: 'Item',
items: 'Items',
open: 'Open',
order: 'Order',
orderDetails: 'Order Details',
orders: 'Orders',
ordersCollectionDescription:
"Orders represent a customer's intent to purchase products from your store. They include details such as the products ordered, quantities, prices, customer information, and order status.",
paymentMethod: 'Payment method',
paymentMethods: 'Payment methods',
pending: 'Pending',
price: 'Price',
priceIn: 'Price ({{currency}})',
priceNotSet: 'Price not set.',
prices: 'Prices',
priceSetInVariants: 'Price set in variants.',
processing: 'Processing',
product: 'Product',
productPriceDescription:
'This price will also be used for sorting and filtering products. If you have variants enabled then you can enter the lowest or average price to help with search and filtering, but this price will not be used for checkout.',
productRequired: 'A product is required.',
products: 'Products',
purchased: 'Purchased',
purchasedAt: 'Purchased at',
quantity: 'Quantity',
refunded: 'Refunded',
shipping: 'Shipping',
shippingAddress: 'Shipping address',
status: 'Status',
subtotal: 'Subtotal',
succeeded: 'Succeeded',
transaction: 'Transaction',
transactionDetails: 'Transaction Details',
transactions: 'Transactions',
transactionsCollectionDescription:
'Transactions represent payment attempts made for an order. An order can have multiple transactions associated with it, such as an initial payment attempt and subsequent refunds or adjustments.',
variant: 'Variant',
variantOption: 'Variant Option',
variantOptions: 'Variant Options',
variantOptionsAlreadyExists:
'This variant combo is already in use by another variant. Please select different options.',
variantOptionsCollectionDescription:
'Variant options define the options a variant type can have, such as red or white for colors.',
variantOptionsRequired: 'Variant options are required.',
variantOptionsRequiredAll: 'All variant options are required.',
variants: 'Variants',
variantsCollectionDescription:
"Product variants allow you to offer different versions of a product, such as size or color variations. They refrence a product's variant options based on the variant types approved.",
variantType: 'Variant Type',
variantTypes: 'Variant Types',
variantTypesCollectionDescription:
'Variant types are used to define the different types of variants your products can have, such as size or color. Each variant type can have multiple options associated with it.',
},
}

View File

@@ -0,0 +1,11 @@
import type { GenericTranslationsObject, NestedKeysStripped } from '@payloadcms/translations'
import { en } from './en.js'
export const translations = {
en,
}
export type EcommerceTranslations = GenericTranslationsObject
export type EcommerceTranslationKeys = NestedKeysStripped<EcommerceTranslations>

View File

@@ -0,0 +1,35 @@
{
"type": "object",
"$schema": "http://json-schema.org/draft-04/schema#",
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"ecommerce": {
"type": "object",
"additionalProperties": false,
"properties": {
"variantOptionsAlreadyExists": {
"type": "string"
},
"productRquired": {
"type": "string"
},
"variantOptionsRequired": {
"type": "string"
},
"variantOptionsRequiredAll": {
"type": "string"
}
},
"required": [
"variantOptionsAlreadyExists",
"productRquired",
"variantOptionsRequired",
"variantOptionsRequiredAll"
]
}
},
"required": ["ecommerce"]
}

View File

@@ -0,0 +1,885 @@
import type {
Access,
CollectionConfig,
DefaultDocumentIDType,
Endpoint,
Field,
FieldAccess,
GroupField,
PayloadRequest,
PopulateType,
SelectType,
TypedCollection,
Where,
} from 'payload'
import type React from 'react'
import type { TypedEcommerce } from './utilities.js'
export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
export type CollectionOverride = (args: {
defaultCollection: CollectionConfig
}) => CollectionConfig | Promise<CollectionConfig>
export type CartItem = {
id: DefaultDocumentIDType
product: DefaultDocumentIDType | TypedCollection['products']
quantity: number
variant?: DefaultDocumentIDType | TypedCollection['variants']
}
type DefaultCartType = {
currency?: string
customer?: DefaultDocumentIDType | TypedCollection['customers']
id: DefaultDocumentIDType
items: CartItem[]
subtotal?: number
}
export type Cart = DefaultCartType
type InitiatePaymentReturnType = {
/**
* Allows for additional data to be returned, such as payment method specific data
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any
message: string
}
type InitiatePayment = (args: {
/**
* The slug of the customers collection, defaults to 'users'.
*/
customersSlug?: string
data: {
/**
* Billing address for the payment.
*/
billingAddress: TypedCollection['addresses']
/**
* Cart items.
*/
cart: Cart
/**
* Currency code to use for the payment.
*/
currency: string
customerEmail: string
/**
* Shipping address for the payment.
*/
shippingAddress?: TypedCollection['addresses']
}
req: PayloadRequest
/**
* The slug of the transactions collection, defaults to 'transactions'.
* For example, this is used to create a record of the payment intent in the transactions collection.
*/
transactionsSlug: string
}) => InitiatePaymentReturnType | Promise<InitiatePaymentReturnType>
type ConfirmOrderReturnType = {
/**
* Allows for additional data to be returned, such as payment method specific data
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any
message: string
orderID: DefaultDocumentIDType
transactionID: DefaultDocumentIDType
}
type ConfirmOrder = (args: {
/**
* The slug of the carts collection, defaults to 'carts'.
* For example, this is used to retrieve the cart for the order.
*/
cartsSlug?: string
/**
* The slug of the customers collection, defaults to 'users'.
*/
customersSlug?: string
/**
* Data made available to the payment method when confirming an order. You should get the cart items from the transaction.
*/
data: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any // Allows for additional data to be passed through, such as payment method specific data
customerEmail?: string
}
/**
* The slug of the orders collection, defaults to 'orders'.
*/
ordersSlug?: string
req: PayloadRequest
/**
* The slug of the transactions collection, defaults to 'transactions'.
* For example, this is used to create a record of the payment intent in the transactions collection.
*/
transactionsSlug?: string
}) => ConfirmOrderReturnType | Promise<ConfirmOrderReturnType>
/**
* The full payment adapter config expected as part of the config for the Ecommerce plugin.
*
* You can insert this type directly or return it from a function constructing it.
*/
export type PaymentAdapter = {
/**
* The function that is called via the `/api/payments/{provider_name}/confirm-order` endpoint to confirm an order after a payment has been made.
*
* You should handle the order confirmation logic here.
*
* @example
*
* ```ts
* const confirmOrder: ConfirmOrder = async ({ data: { customerEmail }, ordersSlug, req, transactionsSlug }) => {
// Confirm the payment with Stripe or another payment provider here
// Create an order in the orders collection here
// Update the record of the payment intent in the transactions collection here
return {
message: 'Order confirmed successfully',
orderID: 'order_123',
transactionID: 'txn_123',
// Include any additional data required for the payment method here
}
}
* ```
*/
confirmOrder: ConfirmOrder
/**
* An array of endpoints to be bootstrapped to Payload's API in order to support the payment method. All API paths are relative to `/api/payments/{provider_name}`.
*
* So for example, path `/webhooks` in the Stripe adapter becomes `/api/payments/stripe/webhooks`.
*
* @example '/webhooks'
*/
endpoints?: Endpoint[]
/**
* A group configuration to be used in the admin interface to display the payment method.
*
* @example
*
* ```ts
* const groupField: GroupField = {
name: 'stripe',
type: 'group',
admin: {
condition: (data) => data?.paymentMethod === 'stripe',
},
fields: [
{
name: 'stripeCustomerID',
type: 'text',
label: 'Stripe Customer ID',
required: true,
},
{
name: 'stripePaymentIntentID',
type: 'text',
label: 'Stripe PaymentIntent ID',
required: true,
},
],
}
* ```
*/
group: GroupField
/**
* The function that is called via the `/api/payments/{provider_name}/initiate` endpoint to initiate a payment for an order.
*
* You should handle the payment initiation logic here.
*
* @example
*
* ```ts
* const initiatePayment: InitiatePayment = async ({ data: { cart, currency, customerEmail, billingAddress, shippingAddress }, req, transactionsSlug }) => {
// Create a payment intent with Stripe or another payment provider here
// Create a record of the payment intent in the transactions collection here
return {
message: 'Payment initiated successfully',
// Include any additional data required for the payment method here
}
}
* ```
*/
initiatePayment: InitiatePayment
/**
* The label of the payment method
* @example
* 'Bank Transfer'
*/
label?: string
/**
* The name of the payment method
* @example 'stripe'
*/
name: string
}
export type PaymentAdapterClient = {
confirmOrder: boolean
initiatePayment: boolean
} & Pick<PaymentAdapter, 'label' | 'name'>
export type Currency = {
/**
* The ISO 4217 currency code
* @example 'usd'
*/
code: string
/**
* The number of decimal places the currency uses
* @example 2
*/
decimals: number
/**
* A user friendly name for the currency.
*
* @example 'US Dollar'
*/
label: string
/**
* The symbol of the currency
* @example '$'
*/
symbol: string
}
/**
* Commonly used arguments for a Payment Adapter function, it's use is entirely optional.
*/
export type PaymentAdapterArgs = {
/**
* Overrides the default fields of the collection. Affects the payment fields on collections such as transactions.
*/
groupOverrides?: { fields?: FieldsOverride } & Partial<Omit<GroupField, 'fields'>>
/**
* The visually readable label for the payment method.
* @example 'Bank Transfer'
*/
label?: string
}
/**
* Commonly used arguments for a Payment Adapter function, it's use is entirely optional.
*/
export type PaymentAdapterClientArgs = {
/**
* The visually readable label for the payment method.
* @example 'Bank Transfer'
*/
label?: string
}
export type VariantsConfig = {
/**
* Override the default variants collection. If you override the collection, you should ensure it has the required fields for variants or re-use the default fields.
*
* @example
*
* ```ts
* variants: {
variantOptionsCollectionOverride: ({ defaultCollection }) => ({
...defaultCollection,
fields: [
...defaultCollection.fields,
{
name: 'customField',
label: 'Custom Field',
type: 'text',
},
],
})
}
```
*/
variantOptionsCollectionOverride?: CollectionOverride
/**
* Override the default variants collection. If you override the collection, you should ensure it has the required fields for variants or re-use the default fields.
*
* @example
*
* ```ts
* variants: {
variantsCollectionOverride: ({ defaultCollection }) => ({
...defaultCollection,
fields: [
...defaultCollection.fields,
{
name: 'customField',
label: 'Custom Field',
type: 'text',
},
],
})
}
```
*/
variantsCollectionOverride?: CollectionOverride
/**
* Override the default variants collection. If you override the collection, you should ensure it has the required fields for variants or re-use the default fields.
*
* @example
*
* ```ts
* variants: {
variantTypesCollectionOverride: ({ defaultCollection }) => ({
...defaultCollection,
fields: [
...defaultCollection.fields,
{
name: 'customField',
label: 'Custom Field',
type: 'text',
},
],
})
}
```
*/
variantTypesCollectionOverride?: CollectionOverride
}
export type ProductsConfig = {
/**
* Override the default products collection. If you override the collection, you should ensure it has the required fields for products or re-use the default fields.
*
* @example
*
* ```ts
products: {
productsCollectionOverride: ({ defaultCollection }) => ({
...defaultCollection,
fields: [
...defaultCollection.fields,
{
name: 'notes',
label: 'Notes',
type: 'textarea',
},
],
})
}
```
*/
productsCollectionOverride?: CollectionOverride
/**
* Customise the validation used for checking products or variants before a transaction is created or a payment can be confirmed.
*/
validation?: ProductsValidation
/**
* Enable variants and provide configuration for the variant collections.
*
* Defaults to true.
*/
variants?: boolean | VariantsConfig
}
export type OrdersConfig = {
/**
* Override the default orders collection. If you override the collection, you should ensure it has the required fields for orders or re-use the default fields.
*
* @example
*
* ```ts
orders: {
ordersCollectionOverride: ({ defaultCollection }) => ({
...defaultCollection,
fields: [
...defaultCollection.fields,
{
name: 'notes',
label: 'Notes',
type: 'textarea',
},
],
})
}
```
*/
ordersCollectionOverride?: CollectionOverride
}
export type TransactionsConfig = {
/**
* Override the default transactions collection. If you override the collection, you should ensure it has the required fields for transactions or re-use the default fields.
*
* @example
*
* ```ts
transactions: {
transactionsCollectionOverride: ({ defaultCollection }) => ({
...defaultCollection,
fields: [
...defaultCollection.fields,
{
name: 'notes',
label: 'Notes',
type: 'textarea',
},
],
})
}
```
*/
transactionsCollectionOverride?: CollectionOverride
}
export type CustomQuery = {
depth?: number
select?: SelectType
where?: Where
}
export type PaymentsConfig = {
paymentMethods?: PaymentAdapter[]
productsQuery?: CustomQuery
variantsQuery?: CustomQuery
}
export type CountryType = {
/**
* A user friendly name for the country.
*/
label: string
/**
* The ISO 3166-1 alpha-2 country code.
* @example 'US'
*/
value: string
}
/**
* Configuration for the addresses used by the Ecommerce plugin. Use this to override the default collection or fields used throughout
*/
type AddressesConfig = {
/**
* Override the default addresses collection. If you override the collection, you should ensure it has the required fields for addresses or re-use the default fields.
*
* @example
* ```ts
* addressesCollectionOverride: (defaultCollection) => {
* return {
* ...defaultCollection,
* fields: [
* ...defaultCollection.fields,
* // add custom fields here
* ],
* }
* }
* ```
*/
addressesCollectionOverride?: CollectionOverride
/**
* These fields will be applied to all locations where addresses are used, such as Orders and Transactions. Preferred use over the collectionOverride config.
*/
addressFields?: FieldsOverride
/**
* Provide an array of countries to support for addresses. This will be used in the admin interface to provide a select field of countries.
*
* Defaults to a set of commonly used countries.
*
* @example
* ```
* [
{ label: 'United States', value: 'US' },
{ label: 'Canada', value: 'CA' },
]
*/
supportedCountries?: CountryType[]
}
export type CustomersConfig = {
/**
* Slug of the customers collection, defaults to 'users'.
* This is used to link carts and orders to customers.
*/
slug: string
}
export type CartsConfig = {
cartsCollectionOverride?: CollectionOverride
}
export type InventoryConfig = {
/**
* Override the default field used to track inventory levels. Defaults to 'inventory'.
*/
fieldName?: string
}
export type CurrenciesConfig = {
/**
* Defaults to the first supported currency.
*
* @example 'USD'
*/
defaultCurrency: string
/**
*
*/
supportedCurrencies: Currency[]
}
/**
* A function that validates a product or variant before a transaction is created or completed.
* This should throw an error if validation fails as it will be caught by the function calling it.
*/
export type ProductsValidation = (args: {
/**
* The full currencies config, allowing you to check against supported currencies and their settings.
*/
currenciesConfig?: CurrenciesConfig
/**
* The ISO 4217 currency code being usen in this transaction.
*/
currency?: string
/**
* The full product data.
*/
product: TypedCollection['products']
/**
* Quantity to check the inventory amount against.
*/
quantity: number
/**
* The full variant data, if a variant was selected for the product otherwise it will be undefined.
*/
variant?: TypedCollection['variants']
}) => Promise<void> | void
/**
* A map of collection slugs used by the Ecommerce plugin.
* Provides an easy way to track the slugs of collections even when they are overridden.
*/
export type CollectionSlugMap = {
addresses: string
carts: string
customers: string
orders: string
products: string
transactions: string
variantOptions: string
variants: string
variantTypes: string
}
/**
* Access control functions used throughout the Ecommerce plugin.
* You must provide these when configuring the plugin.
*
* @example
*
* ```ts
* access: {
adminOnly,
adminOnlyFieldAccess,
adminOrCustomerOwner,
adminOrPublishedStatus,
customerOnlyFieldAccess,
}
```
*/
export type AccessConfig = {
/**
* Limited to only admin users.
*/
adminOnly: Access
/**
* Limited to only admin users, specifically for Field level access control.
*/
adminOnlyFieldAccess: FieldAccess
/**
* Is the owner of the document via the `customer` field or is an admin.
*/
adminOrCustomerOwner: Access
/**
* The document status is published or user is admin.
*/
adminOrPublishedStatus: Access
/**
* Authenticated users only. Defaults to the example function.
*
* @example
* anyUser: ({ req }) => !!req?.user
*/
authenticatedOnly?: Access
/**
* Limited to customers only, specifically for Field level access control.
*/
customerOnlyFieldAccess: FieldAccess
/**
* Entirely public access. Defaults to the example function.
*
* @example
* publicAccess: () => true
*/
publicAccess?: Access
}
export type EcommercePluginConfig = {
/**
* Customise the access control for the plugin.
*
* @example
* ```ts
* ```
*/
access: AccessConfig
/**
* Enable the addresses collection to allow customers, transactions and orders to have multiple addresses for shipping and billing. Accepts an override to customise the addresses collection.
* Defaults to supporting a default set of countries.
*/
addresses?: AddressesConfig | boolean
/**
* Configure the target collection used for carts.
*
* Defaults to true.
*/
carts?: boolean | CartsConfig
/**
* Configure supported currencies and default settings.
*
* Defaults to supporting USD.
*/
currencies?: CurrenciesConfig
/**
* Configure the target collection used for customers.
*
* @example
* ```ts
* customers: {
* slug: 'users', // default
* }
*
*/
customers: CustomersConfig
/**
* Enable tracking of inventory for products and variants. Accepts a config object to override the default collection settings.
*
* Defaults to true.
*/
inventory?: boolean | InventoryConfig
/**
* Enables orders and accepts a config object to override the default collection settings.
*
* Defaults to true.
*/
orders?: boolean | OrdersConfig
/**
* Enable tracking of payments. Accepts a config object to override the default collection settings.
*
* Defaults to true when the paymentMethods array is provided.
*/
payments?: PaymentsConfig
/**
* Enables products and variants. Accepts a config object to override the product collection and each variant collection type.
*
* Defaults to true.
*/
products?: boolean | ProductsConfig
/**
* Override the default slugs used across the plugin. This lets the plugin know which slugs to use for various internal operations and fields.
*/
slugMap?: Partial<CollectionSlugMap>
/**
* Enable tracking of transactions. Accepts a config object to override the default collection settings.
*
* Defaults to true when the paymentMethods array is provided.
*/
transactions?: boolean | TransactionsConfig
}
export type SanitizedEcommercePluginConfig = {
access: Required<AccessConfig>
addresses: { addressFields: Field[] } & Omit<AddressesConfig, 'addressFields'>
currencies: Required<CurrenciesConfig>
inventory?: InventoryConfig
payments: {
paymentMethods: [] | PaymentAdapter[]
}
} & Omit<
Required<EcommercePluginConfig>,
'access' | 'addresses' | 'currencies' | 'inventory' | 'payments'
>
export type EcommerceCollections = TypedEcommerce['collections']
export type AddressesCollection = EcommerceCollections['addresses']
export type CartsCollection = EcommerceCollections['carts']
export type SyncLocalStorageConfig = {
/**
* Key to use for localStorage.
* Defaults to 'cart'.
*/
key?: string
}
type APIProps = {
/**
* The route for the Payload API, defaults to `/api`.
*/
apiRoute?: string
/**
* Customise the query used to fetch carts. Use this when you need to fetch additional data and optimise queries using depth, select and populate.
*
* Defaults to `{ depth: 0 }`.
*/
cartsFetchQuery?: {
depth?: number
populate?: PopulateType
select?: SelectType
}
/**
* The route for the Payload API, defaults to ``. Eg for a Payload app running on `http://localhost:3000`, the default serverURL would be `http://localhost:3000`.
*/
serverURL?: string
}
export type ContextProps = {
/**
* The slug for the addresses collection.
*
* Defaults to 'addresses'.
*/
addressesSlug?: string
api?: APIProps
/**
* The slug for the carts collection.
*
* Defaults to 'carts'.
*/
cartsSlug?: string
children?: React.ReactNode
/**
* The configuration for currencies used in the ecommerce context.
* This is used to handle currency formatting and calculations, defaults to USD.
*/
currenciesConfig?: CurrenciesConfig
/**
* The slug for the customers collection.
*
* Defaults to 'users'.
*/
customersSlug?: string
/**
* Enable debug mode for the ecommerce context. This will log additional information to the console.
* Defaults to false.
*/
debug?: boolean
/**
* Whether to enable support for variants in the cart.
* This allows adding products with specific variants to the cart.
* Defaults to false.
*/
enableVariants?: boolean
/**
* Supported payment methods for the ecommerce context.
*/
paymentMethods?: PaymentAdapterClient[]
/**
* Whether to enable localStorage for cart persistence.
* Defaults to true.
*/
syncLocalStorage?: boolean | SyncLocalStorageConfig
}
/**
* Type used internally to represent the cart item to be added.
*/
type CartItemArgument = {
/**
* The ID of the product to add to the cart. Always required.
*/
product: DefaultDocumentIDType
/**
* The ID of the variant to add to the cart. Optional, if not provided, the product will be added without a variant.
*/
variant?: DefaultDocumentIDType
}
export type EcommerceContextType<T extends EcommerceCollections = EcommerceCollections> = {
/**
* Add an item to the cart.
*/
addItem: (item: CartItemArgument, quantity?: number) => Promise<void>
/**
* All current addresses for the current user.
* This is used to manage shipping and billing addresses.
*/
addresses?: T['addresses'][]
/**
* The current data of the cart.
*/
cart?: T['addresses']
/**
* The ID of the current cart corresponding to the cart in the database or local storage.
*/
cartID?: DefaultDocumentIDType
/**
* Clear the cart, removing all items.
*/
clearCart: () => Promise<void>
/**
* Initiate a payment using the selected payment method.
* This method should be called after the cart is ready for checkout.
* It requires the payment method ID and any necessary payment data.
*/
confirmOrder: (
paymentMethodID: string,
options?: { additionalData: Record<string, unknown> },
) => Promise<unknown>
/**
* Create a new address by providing the data.
*/
createAddress: (data: Partial<T['addresses']>) => Promise<void>
/**
* The configuration for the currencies used in the ecommerce context.
*/
currenciesConfig: CurrenciesConfig
/**
* The currently selected currency used for the cart and price formatting automatically.
*/
currency: Currency
/**
* Decrement an item in the cart by its index ID.
* If quantity reaches 0, the item will be removed from the cart.
*/
decrementItem: (item: DefaultDocumentIDType) => Promise<void>
/**
* Increment an item in the cart by its index ID.
*/
incrementItem: (item: DefaultDocumentIDType) => Promise<void>
/**
* Initiate a payment using the selected payment method.
* This method should be called after the cart is ready for checkout.
* It requires the payment method ID and any necessary payment data.
*/
initiatePayment: (
paymentMethodID: string,
options?: { additionalData: Record<string, unknown> },
) => Promise<unknown>
paymentMethods: PaymentAdapterClient[]
/**
* Remove an item from the cart by its index ID.
*/
removeItem: (item: DefaultDocumentIDType) => Promise<void>
/**
* The name of the currently selected payment method.
* This is used to determine which payment method to use when initiating a payment.
*/
selectedPaymentMethod?: null | string
/**
* Change the currency for the cart, it defaults to the configured currency.
* This will update the currency used for pricing and calculations.
*/
setCurrency: (currency: string) => void
/**
* Update an address by providing the data and the ID.
*/
updateAddress: (addressID: DefaultDocumentIDType, data: Partial<T['addresses']>) => Promise<void>
}

View File

@@ -0,0 +1,43 @@
import type { DefaultDocumentIDType, GeneratedTypes } from 'payload'
/**
* THIS FILE IS EXTREMELY SENSITIVE PLEASE BE CAREFUL AS THERE IS EVIL AT PLAY
*
* This file is used to extract the types for the ecommerce plugin
* from the user's generated types. It must be kept as a .ts file
* and not a .tsx file, and it must not import any other files
* that are not strictly types. This is to prevent circular
* dependencies and to ensure that the types are always available.
*
* Do not add any runtime code to this file.
*/
type CartsUntyped = {
[key: string]: any
id: DefaultDocumentIDType
items?: any[]
subtotal?: number
}
type AddressesUntyped = {
[key: string]: any
id: DefaultDocumentIDType
}
type ResolveEcommerceType<T> = 'ecommerce' extends keyof T
? T['ecommerce']
: // @ts-expect-error - typescript doesnt play nice here
T['ecommerceUntyped']
export type TypedEcommerce = ResolveEcommerceType<GeneratedTypes>
declare module 'payload' {
export interface GeneratedTypes {
ecommerceUntyped: {
collections: {
addresses: AddressesUntyped
carts: CartsUntyped
}
}
}
}

View File

@@ -0,0 +1,49 @@
'use client'
import type { DefaultCellComponentProps, TypedCollection } from 'payload'
import { useTranslation } from '@payloadcms/ui'
import type { CurrenciesConfig, Currency } from '../../types/index.js'
import { convertFromBaseValue } from '../utilities.js'
type Props = {
cellData?: number
currenciesConfig: CurrenciesConfig
currency?: Currency
path: string
rowData: Partial<TypedCollection['products']>
} & DefaultCellComponentProps
export const PriceCell: React.FC<Props> = (args) => {
const { t } = useTranslation()
const { cellData, currenciesConfig, currency: currencyFromProps, rowData } = args
const currency = currencyFromProps || currenciesConfig.supportedCurrencies[0]
if (!currency) {
// @ts-expect-error - plugin translations are not typed yet
return <span>{t('plugin-ecommerce:currencyNotSet')}</span>
}
if (
(!cellData || typeof cellData !== 'number') &&
'enableVariants' in rowData &&
rowData.enableVariants
) {
// @ts-expect-error - plugin translations are not typed yet
return <span>{t('plugin-ecommerce:priceSetInVariants')}</span>
}
if (!cellData) {
// @ts-expect-error - plugin translations are not typed yet
return <span>{t('plugin-ecommerce:priceNotSet')}</span>
}
return (
<span>
{currency.symbol}
{convertFromBaseValue({ baseValue: cellData, currency })}
</span>
)
}

View File

@@ -0,0 +1,166 @@
'use client'
import type { StaticDescription, StaticLabel } from 'payload'
import { FieldDescription, FieldLabel, useField, useFormFields } from '@payloadcms/ui'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Currency } from '../../types/index.js'
import { USD } from '../../currencies/index.js'
import { convertFromBaseValue, convertToBaseValue } from '../utilities.js'
interface Props {
currency?: Currency
description?: StaticDescription
disabled?: boolean
error?: string
id?: string
label?: StaticLabel
path: string
placeholder?: string
readOnly?: boolean
supportedCurrencies: Currency[]
}
const baseClass = 'formattedPrice'
export const FormattedInput: React.FC<Props> = ({
id: idFromProps,
currency: currencyFromProps,
description,
disabled = false,
label,
path,
placeholder = '0.00',
readOnly,
supportedCurrencies,
}) => {
const { setValue, value } = useField<number>({ path })
const [displayValue, setDisplayValue] = useState<string>('')
const inputRef = useRef<HTMLInputElement>(null)
const isFirstRender = useRef(true)
const debounceTimer = useRef<NodeJS.Timeout | null>(null)
const parentPath = path.split('.').slice(0, -1).join('.')
const currencyPath = parentPath ? `${parentPath}.currency` : 'currency'
const currencyFromSelectField = useFormFields(([fields, _]) => fields[currencyPath])
const currencyCode = currencyFromProps?.code ?? (currencyFromSelectField?.value as string)
const id = idFromProps || path
const currency = useMemo<Currency>(() => {
if (currencyCode && supportedCurrencies) {
const foundCurrency = supportedCurrencies.find(
(supportedCurrency) => supportedCurrency.code === currencyCode,
)
return foundCurrency ?? supportedCurrencies[0] ?? USD
}
return supportedCurrencies[0] ?? USD
}, [currencyCode, supportedCurrencies])
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
if (value === undefined || value === null) {
setDisplayValue('')
} else {
setDisplayValue(convertFromBaseValue({ baseValue: value, currency }))
}
}
}, [currency, value, currencyFromProps])
const updateValue = useCallback(
(inputValue: string) => {
if (inputValue === '') {
setValue(null)
return
}
const baseValue = convertToBaseValue({ currency, displayValue: inputValue })
setValue(baseValue)
},
[currency, setValue],
)
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value
if (!/^\d*(?:\.\d*)?$/.test(inputValue) && inputValue !== '') {
return
}
setDisplayValue(inputValue)
// Clear any existing timer
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
// Only update the base value after a delay to avoid formatting while typing
debounceTimer.current = setTimeout(() => {
updateValue(inputValue)
}, 500)
},
[updateValue, setDisplayValue],
)
const handleInputBlur = useCallback(() => {
if (displayValue === '') {
return
}
// Clear any pending debounce
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
debounceTimer.current = null
}
const baseValue = convertToBaseValue({ currency, displayValue })
const formattedValue = convertFromBaseValue({ baseValue, currency })
if (value != baseValue) {
setValue(baseValue)
}
setDisplayValue(formattedValue)
}, [currency, displayValue, setValue, value])
return (
<div className={`field-type number ${baseClass}`}>
{label && <FieldLabel as="label" htmlFor={id} label={label} />}
<div className={`${baseClass}Container`}>
<div className={`${baseClass}CurrencySymbol`}>
<span>{currency.symbol}</span>
</div>
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
<input
className={`${baseClass}Input`}
disabled={disabled || readOnly}
id={id}
onBlur={handleInputBlur}
onChange={handleInputChange}
placeholder={placeholder}
ref={inputRef}
type="text"
value={displayValue}
/>
</div>
<FieldDescription
className={`${baseClass}Description`}
description={description}
path={path}
/>
</div>
)
}

View File

@@ -0,0 +1,35 @@
.formattedPrice {
.formattedPriceLabel {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: #333;
}
.formattedPriceContainer {
display: flex;
position: relative;
}
.formattedPriceCurrencySymbol {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(calc(-50%));
color: var(--theme-elevation-500);
user-select: none;
pointer-events: none;
}
.formattedPriceInput {
padding: 0.5rem 0.5rem 0.5rem 1.75rem;
min-width: 2rem;
width: fit-content;
max-width: 10rem;
}
.formattedPriceDescription {
max-width: 46rem;
}
}

View File

@@ -0,0 +1,44 @@
import type { NumberFieldServerProps } from 'payload'
import './index.css'
import type { CurrenciesConfig, Currency } from '../../types/index.js'
import { FormattedInput } from './FormattedInput.js'
type Props = {
currenciesConfig: CurrenciesConfig
currency?: Currency
path: string
} & NumberFieldServerProps
export const PriceInput: React.FC<Props> = (args) => {
const {
clientField: { label },
currenciesConfig,
currency: currencyFromProps,
field,
i18n: { t },
i18n,
path,
readOnly,
} = args
const description = field.admin?.description
? typeof field.admin.description === 'function'
? // @ts-expect-error - weird type issue on 't' here
field.admin.description({ i18n, t })
: field.admin.description
: undefined
return (
<FormattedInput
currency={currencyFromProps}
description={description}
label={label}
path={path}
readOnly={readOnly}
supportedCurrencies={currenciesConfig?.supportedCurrencies}
/>
)
}

View File

@@ -0,0 +1,13 @@
@layer payload-default {
.priceRowLabel {
display: flex;
align-items: center;
gap: calc(var(--base) * 1);
}
.priceValue {
display: flex;
align-items: center;
gap: calc(var(--base) * 0.25);
}
}

View File

@@ -0,0 +1,56 @@
'use client'
import { useRowLabel } from '@payloadcms/ui'
import { useMemo } from 'react'
import type { CurrenciesConfig } from '../../types/index.js'
import './index.css'
import { convertFromBaseValue } from '../utilities.js'
type Props = {
currenciesConfig: CurrenciesConfig
}
export const PriceRowLabel: React.FC<Props> = (props) => {
const { currenciesConfig } = props
const { defaultCurrency, supportedCurrencies } = currenciesConfig
const { data } = useRowLabel<{ amount: number; currency: string }>()
const currency = useMemo(() => {
if (data.currency) {
return supportedCurrencies.find((c) => c.code === data.currency) ?? supportedCurrencies[0]
}
const fallbackCurrency = supportedCurrencies.find((c) => c.code === defaultCurrency)
if (fallbackCurrency) {
return fallbackCurrency
}
return supportedCurrencies[0]
}, [data.currency, supportedCurrencies, defaultCurrency])
const amount = useMemo(() => {
if (data.amount) {
return convertFromBaseValue({ baseValue: data.amount, currency: currency! })
}
return '0'
}, [currency, data.amount])
return (
<div className="priceRowLabel">
<div className="priceLabel">Price:</div>
<div className="priceValue">
<span>
{currency?.symbol}
{amount}
</span>
<span>({data.currency})</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
'use client'
import { FieldError, useField, useTranslation } from '@payloadcms/ui'
type Props = {
children?: React.ReactNode
existingOptions: string[]
path: string
}
export const ErrorBox: React.FC<Props> = (props) => {
const { children, path } = props
const { errorMessage, showError } = useField<(number | string)[]>({ path })
return (
<div className="variantOptionsSelectorError">
<FieldError message={errorMessage} path={path} showError={showError} />
<div
className={['variantOptionsSelectorErrorWrapper', showError && 'showError']
.filter(Boolean)
.join(' ')}
>
{children}
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import type { SelectFieldClient } from 'payload'
import { FieldLabel, ReactSelect, useField, useForm } from '@payloadcms/ui'
import { useCallback, useId, useMemo } from 'react'
type Props = {
field: Omit<SelectFieldClient, 'type'>
label: string
options: { label: string; value: number | string }[]
path: string
}
export const OptionsSelect: React.FC<Props> = (props) => {
const {
field: { required },
label,
options: optionsFromProps,
path,
} = props
const { setValue, value } = useField<(number | string)[]>({ path })
const id = useId()
const selectedValue = useMemo(() => {
if (!value || !Array.isArray(value) || value.length === 0) {
return undefined
}
const foundOption = optionsFromProps.find((option) => {
return value.find((item) => item === option.value)
})
return foundOption
}, [optionsFromProps, value])
const handleChange = useCallback(
// @ts-expect-error - TODO: Fix types
(option) => {
if (selectedValue) {
let selectedValueIndex = -1
const valuesWithoutSelected = [...value].filter((o, index) => {
if (o === selectedValue.value) {
selectedValueIndex = index
return false
}
return true
})
const newValues = [...valuesWithoutSelected]
newValues.splice(selectedValueIndex, 0, option.value)
setValue(newValues)
} else {
const values = [...(value || []), option.value]
setValue(values)
}
},
[selectedValue, setValue, value],
)
return (
<div className="variantOptionsSelectorItem">
<FieldLabel htmlFor={id} label={label} required={required} />
<ReactSelect
inputId={id}
onChange={handleChange}
options={optionsFromProps}
value={selectedValue}
/>
</div>
)
}

View File

@@ -0,0 +1,37 @@
@layer payload-default {
.variantOptionsSelector {
margin-top: calc(var(--spacing-field) * 2);
margin-bottom: calc(var(--spacing-field) * 2);
}
.variantOptionsSelectorHeading {
font-size: calc(var(--base) * 1);
font-weight: 500;
color: var(--color-text);
margin-bottom: calc(var(--base) * 0.5);
}
.variantOptionsSelectorItem {
display: flex;
flex-direction: column;
gap: 0;
}
.variantOptionsSelectorList {
display: flex;
flex-direction: column;
gap: calc(var(--base) * 0.75);
}
.variantOptionsSelectorError {
position: relative;
}
.variantOptionsSelectorErrorWrapper {
&.showError {
border-radius: 2px;
outline: 1px solid var(--theme-error-400);
outline-offset: 2px;
}
}
}

View File

@@ -0,0 +1,84 @@
import type { SelectFieldServerProps } from 'payload'
import { FieldLabel } from '@payloadcms/ui'
import { ErrorBox } from './ErrorBox.js'
import './index.css'
import { OptionsSelect } from './OptionsSelect.js'
type Props = {} & SelectFieldServerProps
export const VariantOptionsSelector: React.FC<Props> = async (props) => {
const { clientField, data, path, req, user } = props
const { label } = clientField
const product = await req.payload.findByID({
id: data.product,
collection: 'products',
depth: 0,
draft: true,
select: {
variants: true,
variantTypes: true,
},
user,
})
// @ts-expect-error - TODO: Fix types
const existingVariantOptions = product.variants?.docs?.map((variant) => variant.options) ?? []
const variantTypeIDs = product.variantTypes
const variantTypes = []
// Need to get the variant types separately so that the options are populated
// @ts-expect-error - TODO: Fix types
if (variantTypeIDs?.length && variantTypeIDs.length > 0) {
// @ts-expect-error - TODO: Fix types
for (const variantTypeID of variantTypeIDs) {
const variantType = await req.payload.findByID({
id: variantTypeID,
collection: 'variantTypes',
depth: 1,
joins: {
options: {
sort: 'value',
},
},
})
if (variantType) {
variantTypes.push(variantType)
}
}
}
return (
<div className="variantOptionsSelector">
<div className="variantOptionsSelectorHeading">
<FieldLabel as="span" label={label} />
</div>
<ErrorBox existingOptions={existingVariantOptions} path={path}>
<div className="variantOptionsSelectorList">
{variantTypes.map((type) => {
// @ts-expect-error - TODO: Fix types
const options = type.options.docs.map((option) => ({
label: option.label,
value: option.id,
}))
return (
<OptionsSelect
field={clientField}
key={type.name}
label={type.label || type.name}
options={options}
path={path}
/>
)
})}
</div>
</ErrorBox>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import type { Currency } from '../types/index.js'
/**
* Convert display value with decimal point to base value (e.g., $25.00 to 2500)
*/
export const convertToBaseValue = ({
currency,
displayValue,
}: {
currency: Currency
displayValue: string
}): number => {
if (!currency) {
return parseFloat(displayValue)
}
// Remove currency symbol and any non-numeric characters except decimal
const cleanValue = displayValue.replace(currency.symbol, '').replace(/[^0-9.]/g, '')
// Parse the clean value to a float
const floatValue = parseFloat(cleanValue || '0')
// Convert to the base value (e.g., cents for USD)
return Math.round(floatValue * Math.pow(10, currency.decimals))
}
/**
* Convert base value to display value with decimal point (e.g., 2500 to $25.00)
*/
export const convertFromBaseValue = ({
baseValue,
currency,
}: {
baseValue: number
currency: Currency
}): string => {
if (!currency) {
return baseValue.toString()
}
// Convert from base value (e.g., cents) to decimal value (e.g., dollars)
const decimalValue = baseValue / Math.pow(10, currency.decimals)
// Format with the correct number of decimal places
return decimalValue.toFixed(currency.decimals)
}

View File

@@ -0,0 +1,42 @@
import type { ProductsValidation } from '../types/index.js'
import { MissingPrice, OutOfStock } from './errorCodes.js'
export const defaultProductsValidation: ProductsValidation = ({
currenciesConfig,
currency,
product,
quantity = 1,
variant,
}) => {
if (!currency) {
throw new Error('Currency must be provided for product validation.')
}
const priceField = `priceIn${currency.toUpperCase()}`
if (variant) {
if (!variant[priceField]) {
throw new Error(`Variant with ID ${variant.id} does not have a price in ${currency}.`)
}
if (variant.inventory === 0 || (variant.inventory && variant.inventory < quantity)) {
throw new Error(
`Variant with ID ${variant.id} is out of stock or does not have enough inventory.`,
)
}
} else if (product) {
// Validate the product's details only if the variant is not provided as it can have its own inventory and price
if (!product[priceField]) {
throw new Error(`Product does not have a price in.`, {
cause: { code: MissingPrice, codes: [product.id, currency] },
})
}
if (product.inventory === 0 || (product.inventory && product.inventory < quantity)) {
throw new Error(`Product is out of stock or does not have enough inventory.`, {
cause: { code: OutOfStock, codes: [product.id] },
})
}
}
}

View File

@@ -0,0 +1,2 @@
export const MissingPrice = 'MissingPrice'
export const OutOfStock = 'OutOfStock'

View File

@@ -0,0 +1,31 @@
import type { CollectionSlugMap, SanitizedEcommercePluginConfig } from '../types/index.js'
type Props = {
sanitizedPluginConfig: SanitizedEcommercePluginConfig
}
/**
* Generates a map of collection slugs based on the sanitized plugin configuration.
* Takes into consideration any collection overrides provided in the plugin.
*/
export const getCollectionSlugMap = ({ sanitizedPluginConfig }: Props): CollectionSlugMap => {
const defaultSlugMap: CollectionSlugMap = {
addresses: 'addresses',
carts: 'carts',
customers: 'users',
orders: 'orders',
products: 'products',
transactions: 'transactions',
variantOptions: 'variantOptions',
variants: 'variants',
variantTypes: 'variantTypes',
}
const collectionSlugsMap: CollectionSlugMap = defaultSlugMap
if (typeof sanitizedPluginConfig.customers === 'object' && sanitizedPluginConfig.customers.slug) {
collectionSlugsMap.customers = sanitizedPluginConfig.customers.slug
}
return { ...collectionSlugsMap, ...(sanitizedPluginConfig.slugMap || {}) }
}

View File

@@ -0,0 +1,51 @@
import type { I18n } from '@payloadcms/translations'
import type { JSONSchema4 } from 'json-schema'
import type { SanitizedConfig } from 'payload'
import type { CollectionSlugMap, SanitizedEcommercePluginConfig } from '../types/index.js'
export const pushTypeScriptProperties = ({
collectionSlugMap,
jsonSchema,
}: {
collectionSlugMap: CollectionSlugMap
config: SanitizedConfig
jsonSchema: JSONSchema4
sanitizedPluginConfig: SanitizedEcommercePluginConfig
}): JSONSchema4 => {
if (!jsonSchema.properties) {
jsonSchema.properties = {}
}
if (Array.isArray(jsonSchema.required)) {
jsonSchema.required.push('ecommerce')
}
const requiredCollectionProperties: string[] = []
const propertiesMap = new Map<string, { $ref: string }>()
Object.entries(collectionSlugMap).forEach(([key, slug]) => {
propertiesMap.set(key, { $ref: `#/definitions/${slug}` })
requiredCollectionProperties.push(slug)
})
jsonSchema.properties.ecommerce = {
type: 'object',
additionalProperties: false,
description: 'Generated by the Payload Ecommerce plugin',
properties: {
collections: {
type: 'object',
additionalProperties: false,
properties: {
...Object.fromEntries(propertiesMap),
},
required: requiredCollectionProperties,
},
},
required: ['collections'],
}
return jsonSchema
}

View File

@@ -0,0 +1,92 @@
import type { EcommercePluginConfig, SanitizedEcommercePluginConfig } from '../types/index.js'
import { defaultAddressFields } from '../collections/addresses/defaultAddressFields.js'
import { USD } from '../currencies/index.js'
type Props = {
pluginConfig: EcommercePluginConfig
}
export const sanitizePluginConfig = ({ pluginConfig }: Props): SanitizedEcommercePluginConfig => {
const config = {
...pluginConfig,
} as Partial<SanitizedEcommercePluginConfig>
if (typeof config.customers === 'undefined') {
config.customers = {
slug: 'users',
}
}
if (
typeof config.addresses === 'undefined' ||
(typeof config.addresses === 'boolean' && config.addresses === true)
) {
config.addresses = {
addressFields: defaultAddressFields(),
}
} else {
const addressFields =
(typeof pluginConfig.addresses === 'object' &&
typeof pluginConfig.addresses.addressFields === 'function' &&
pluginConfig.addresses.addressFields({
defaultFields: defaultAddressFields(),
})) ||
defaultAddressFields()
config.addresses = {
...config.addresses,
addressFields,
}
}
if (!config.currencies) {
config.currencies = {
defaultCurrency: 'USD',
supportedCurrencies: [USD],
}
}
if (
typeof config.inventory === 'undefined' ||
(typeof config.inventory === 'boolean' && config.inventory === true)
) {
config.inventory = {
fieldName: 'inventory',
}
}
if (typeof config.carts === 'undefined') {
config.carts = true
}
if (typeof config.orders === 'undefined') {
config.orders = true
}
if (typeof config.transactions === 'undefined') {
config.transactions = true
}
if (typeof config.payments === 'undefined') {
config.payments = {
paymentMethods: [],
}
} else if (!config.payments.paymentMethods) {
config.payments.paymentMethods = []
}
if (config.products) {
if (typeof config.products === 'object' && typeof config.products.variants === 'undefined') {
config.products.variants = true
}
}
config.access = {
authenticatedOnly: ({ req: { user } }) => Boolean(user),
publicAccess: () => true,
...pluginConfig.access,
}
return config as SanitizedEcommercePluginConfig
}

View File

@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
},
"references": [{ "path": "../payload" }, { "path": "../ui" }, { "path": "../translations" }]
}

2148
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,5 +4,6 @@ packages:
- 'test' - 'test'
- 'templates/blank' - 'templates/blank'
- 'templates/website' - 'templates/website'
- 'templates/ecommerce'
updateNotifier: false updateNotifier: false

View File

@@ -0,0 +1,18 @@
PAYLOAD_SECRET=mygeneratedsecret
DATABASE_URI=mongodb://127.0.0.1/template-ecommerce
COMPANY_NAME="Payload Inc."
TWITTER_CREATOR="@payloadcms"
TWITTER_SITE="https://nextjs.org/commerce"
SITE_NAME="Payload Commerce"
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
# Used to preview drafts
PREVIEW_SECRET=demo-draft-secret
# Stripe API keys
STRIPE_SECRET_KEY=sk_test_
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_
STRIPE_WEBHOOKS_SIGNING_SECRET=whsec_

43
templates/ecommerce/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
.playwright
playwright-report/
playwright-report/index.html
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
public/media

View File

@@ -0,0 +1,2 @@
legacy-peer-deps=true
enable-pre-post-scripts=true

View File

@@ -0,0 +1,17 @@
.vercel
.next
pnpm-lock.yaml
**/payload-types.ts
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp
**/docs/**
tsconfig.json

View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": false
}

28
templates/ecommerce/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,28 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev",
"serverReadyAction": {
"pattern": "started server on .+, url: (https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

View File

@@ -0,0 +1,9 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

View File

@@ -0,0 +1,376 @@
# Payload Ecommerce Template
This template is in **BETA**.
This is the official [Payload Ecommerce Template](https://github.com/payloadcms/payload/blob/main/templates/ecommerce). This repo includes a fully-working backend, enterprise-grade admin panel, and a beautifully designed, production-ready ecommerce website.
This template is right for you if you are working on building an ecommerce project or shop with Payload.
Core features:
- [Pre-configured Payload Config](#how-it-works)
- [Authentication](#users-authentication)
- [Access Control](#access-control)
- [Layout Builder](#layout-builder)
- [Draft Preview](#draft-preview)
- [Live Preview](#live-preview)
- [On-demand Revalidation](#on-demand-revalidation)
- [SEO](#seo)
- [Search & Filters](#search)
- [Jobs and Scheduled Publishing](#jobs-and-scheduled-publish)
- [Website](#website)
- [Products & Variants](#products-and-variants)
- [User accounts](#user-accounts)
- [Carts](#carts)
- [Guest checkout](#guests)
- [Orders & Transactions](#orders-and-transactions)
- [Stripe Payments](#stripe)
- [Currencies](#currencies)
- [Automated Tests](#tests)
## Quick Start
To spin up this example locally, follow these steps:
### Clone
If you have not done so already, you need to have standalone copy of this repo on your machine. If you've already cloned this repo, skip to [Development](#development).
#### Method 1
Use the `create-payload-app` CLI to clone this template directly to your machine:
```bash
pnpx create-payload-app my-project -t ecommerce
```
#### Method 2
Use the `git` CLI to clone this template directly to your machine:
```bash
git clone -n --depth=1 --filter=tree:0 https://github.com/payloadcms/payload my-project && cd my-project && git sparse-checkout set --no-cone templates/ecommerce && git checkout && rm -rf .git && git init && git add . && git mv -f templates/ecommerce/{.,}* . && git add . && git commit -m "Initial commit"
```
### Development
1. First [clone the repo](#clone) if you have not done so already
1. `cd my-project && cp .env.example .env` to copy the example environment variables
1. `pnpm install && pnpm dev` to install dependencies and start the dev server
1. open `http://localhost:3000` to open the app in your browser
That's it! Changes made in `./src` will be reflected in your app. Follow the on-screen instructions to login and create your first admin user. Then check out [Production](#production) once you're ready to build and serve your app, and [Deployment](#deployment) when you're ready to go live.
## How it works
The Payload config is tailored specifically to the needs of most websites. It is pre-configured in the following ways:
### Collections
See the [Collections](https://payloadcms.com/docs/configuration/collections) docs for details on how to extend this functionality.
- #### Users (Authentication)
Users are auth-enabled collections that have access to the admin panel and unpublished content. See [Access Control](#access-control) for more details.
For additional help, see the official [Auth Example](https://github.com/payloadcms/payload/tree/main/examples/auth) or the [Authentication](https://payloadcms.com/docs/authentication/overview#authentication-overview) docs.
- #### Pages
All pages are layout builder enabled so you can generate unique layouts for each page using layout-building blocks, see [Layout Builder](#layout-builder) for more details. Pages are also draft-enabled so you can preview them before publishing them to your website, see [Draft Preview](#draft-preview) for more details.
- #### Media
This is the uploads enabled collection used by pages, posts, and projects to contain media like images, videos, downloads, and other assets. It features pre-configured sizes, focal point and manual resizing to help you manage your pictures.
- #### Categories
A taxonomy used to group products together.
- ### Carts
Used to track user and guest carts within Payload. Added by the [ecommerce plugin](https://payloadcms.com/docs/plugins/ecommerce#carts).
- ### Addresses
Saves user's addresses for easier checkout. Added by the [ecommerce plugin](https://payloadcms.com/docs/plugins/ecommerce#addresses).
- ### Orders
Tracks orders once a transaction successfully completes. Added by the [ecommerce plugin](https://payloadcms.com/docs/plugins/ecommerce#orders).
- ### Transactions
Tracks transactions from initiation to completion, once completed they will have a related Order item. Added by the [ecommerce plugin](https://payloadcms.com/docs/plugins/ecommerce#transactions).
- ### Products and Variants
Primary collections for product details such as pricing per currency and optionally supports variants per product. Added by the [ecommerce plugin](https://payloadcms.com/docs/plugins/ecommerce#products).
### Globals
See the [Globals](https://payloadcms.com/docs/configuration/globals) docs for details on how to extend this functionality.
- `Header`
The data required by the header on your front-end like nav links.
- `Footer`
Same as above but for the footer of your site.
## Access control
Basic access control is setup to limit access to various content based based on publishing status.
- `users`: Users with the `admin` role can access the admin panel and create or edit content, users with the `customer` role can only access the frontend and the relevant collection items to themselves.
- `pages`: Everyone can access published pages, but only admin users can create, update, or delete them.
- `products` `variants`: Everyone can access published products, but only admin users can create, update, or delete them.
- `carts`: Customers can access their own saved cart, guest users can access any unclaimed cart by ID.
- `addresses`: Customers can access their own addresses for record keeping.
- `transactions`: Only admins can access these as they're meant for internal tracking.
- `orders`: Only admins and users who own the orders can access these.
For more details on how to extend this functionality, see the [Payload Access Control](https://payloadcms.com/docs/access-control/overview#access-control) docs.
## User accounts
## Guests
## Layout Builder
Create unique page layouts for any type of content using a powerful layout builder. This template comes pre-configured with the following layout building blocks:
- Hero
- Content
- Media
- Call To Action
- Archive
Each block is fully designed and built into the front-end website that comes with this template. See [Website](#website) for more details.
## Lexical editor
A deep editorial experience that allows complete freedom to focus just on writing content without breaking out of the flow with support for Payload blocks, media, links and other features provided out of the box. See [Lexical](https://payloadcms.com/docs/rich-text/overview) docs.
## Draft Preview
All products and pages are draft-enabled so you can preview them before publishing them to your website. To do this, these collections use [Versions](https://payloadcms.com/docs/configuration/collections#versions) with `drafts` set to `true`. This means that when you create a new product or page, it will be saved as a draft and will not be visible on your website until you publish it. This also means that you can preview your draft before publishing it to your website. To do this, we automatically format a custom URL which redirects to your front-end to securely fetch the draft version of your content.
Since the front-end of this template is statically generated, this also means that pages, products, and projects will need to be regenerated as changes are made to published documents. To do this, we use an `afterChange` hook to regenerate the front-end when a document has changed and its `_status` is `published`.
For more details on how to extend this functionality, see the official [Draft Preview Example](https://github.com/payloadcms/payload/tree/examples/draft-preview).
## Live preview
In addition to draft previews you can also enable live preview to view your end resulting page as you're editing content with full support for SSR rendering. See [Live preview docs](https://payloadcms.com/docs/live-preview/overview) for more details.
## On-demand Revalidation
We've added hooks to collections and globals so that all of your pages, products, footer, or header changes will automatically be updated in the frontend via on-demand revalidation supported by Nextjs.
> Note: if an image has been changed, for example it's been cropped, you will need to republish the page it's used on in order to be able to revalidate the Nextjs image cache.
## SEO
This template comes pre-configured with the official [Payload SEO Plugin](https://payloadcms.com/docs/plugins/seo) for complete SEO control from the admin panel. All SEO data is fully integrated into the front-end website that comes with this template. See [Website](#website) for more details.
## Search
This template comes with SSR search features can easily be implemented into Next.js with Payload. See [Website](#website) for more details.
## Orders and Transactions
Transactions are intended for keeping a record of any payment made, as such it will contain information regarding an order or billing address used or the payment method used and amount. Only admins can access transactions.
An order is created only once a transaction is successfully completed. This is a record that the user who completed the transaction has access so they can keep track of their history. Guests can also access their own orders by providing an order ID and the email associated with that order.
## Currencies
By default the template ships with support only for USD however you can change the supported currencies via the [plugin configuration](https://payloadcms.com/docs/plugins/ecommerce#currencies). You will need to ensure that the supported currencies in Payload are also configured in your Payment platforms.
## Stripe
By default we ship with the Stripe adapter configured, so you'll need to setup the `secretKey`, `publishableKey` and `webhookSecret` from your Stripe dashboard. Follow [Stripe's guide](https://docs.stripe.com/get-started/api-request?locale=en-GB) on how to set this up.
## Tests
We provide automated tests out of the box for both E2E and Int tests along with this template. They are being run in our CI to ensure the stability of this template over time. You can integrate them into your CI or run them locally as well via:
To run Int tests wtih Vitest:
```bash
pnpm test:int
```
To run E2Es with Playwright:
```bash
pnpm test:e2e
```
or
```bash
pnpm test
```
To run both.
## Jobs and Scheduled Publish
We have configured [Scheduled Publish](https://payloadcms.com/docs/versions/drafts#scheduled-publish) which uses the [jobs queue](https://payloadcms.com/docs/jobs-queue/jobs) in order to publish or unpublish your content on a scheduled time. The tasks are run on a cron schedule and can also be run as a separate instance if needed.
> Note: When deployed on Vercel, depending on the plan tier, you may be limited to daily cron only.
## Website
This template includes a beautifully designed, production-ready front-end built with the [Next.js App Router](https://nextjs.org), served right alongside your Payload app in a instance. This makes it so that you can deploy both your backend and website where you need it.
Core features:
- [Next.js App Router](https://nextjs.org)
- [TypeScript](https://www.typescriptlang.org)
- [React Hook Form](https://react-hook-form.com)
- [Payload Admin Bar](https://github.com/payloadcms/payload/tree/main/packages/admin-bar)
- [TailwindCSS styling](https://tailwindcss.com/)
- [shadcn/ui components](https://ui.shadcn.com/)
- User Accounts and Authentication
- Fully featured blog
- Publication workflow
- Dark mode
- Pre-made layout building blocks
- SEO
- Search
- Live preview
- Stripe payments
### Cache
Although Next.js includes a robust set of caching strategies out of the box, Payload Cloud proxies and caches all files through Cloudflare using the [Official Cloud Plugin](https://www.npmjs.com/package/@payloadcms/payload-cloud). This means that Next.js caching is not needed and is disabled by default. If you are hosting your app outside of Payload Cloud, you can easily reenable the Next.js caching mechanisms by removing the `no-store` directive from all fetch requests in `./src/app/_api` and then removing all instances of `export const dynamic = 'force-dynamic'` from pages files, such as `./src/app/(pages)/[slug]/page.tsx`. For more details, see the official [Next.js Caching Docs](https://nextjs.org/docs/app/building-your-application/caching).
## Development
To spin up this example locally, follow the [Quick Start](#quick-start). Then [Seed](#seed) the database with a few pages, posts, and projects.
### Working with Postgres
Postgres and other SQL-based databases follow a strict schema for managing your data. In comparison to our MongoDB adapter, this means that there's a few extra steps to working with Postgres.
Note that often times when making big schema changes you can run the risk of losing data if you're not manually migrating it.
#### Local development
Ideally we recommend running a local copy of your database so that schema updates are as fast as possible. By default the Postgres adapter has `push: true` for development environments. This will let you add, modify and remove fields and collections without needing to run any data migrations.
If your database is pointed to production you will want to set `push: false` otherwise you will risk losing data or having your migrations out of sync.
#### Migrations
[Migrations](https://payloadcms.com/docs/database/migrations) are essentially SQL code versions that keeps track of your schema. When deploy with Postgres you will need to make sure you create and then run your migrations.
Locally create a migration
```bash
pnpm payload migrate:create
```
This creates the migration files you will need to push alongside with your new configuration.
On the server after building and before running `pnpm start` you will want to run your migrations
```bash
pnpm payload migrate
```
This command will check for any migrations that have not yet been run and try to run them and it will keep a record of migrations that have been run in the database.
### Docker
Alternatively, you can use [Docker](https://www.docker.com) to spin up this template locally. To do so, follow these steps:
1. Follow [steps 1 and 2 from above](#development), the docker-compose file will automatically use the `.env` file in your project root
1. Next run `docker-compose up`
1. Follow [steps 4 and 5 from above](#development) to login and create your first admin user
That's it! The Docker instance will help you get up and running quickly while also standardizing the development environment across your teams.
### Seed
To seed the database with a few pages, products, and orders you can click the 'seed database' link from the admin panel.
The seed script will also create a demo user for demonstration purposes only:
- Demo Customer
- Email: `customer@example.com`
- Password: `password`
> NOTICE: seeding the database is destructive because it drops your current database to populate a fresh one from the seed template. Only run this command if you are starting a new project or can afford to lose your current data.
## Production
To run Payload in production, you need to build and start the Admin panel. To do so, follow these steps:
1. Invoke the `next build` script by running `pnpm build` or `npm run build` in your project root. This creates a `.next` directory with a production-ready admin bundle.
1. Finally run `pnpm start` or `npm run start` to run Node in production and serve Payload from the `.build` directory.
1. When you're ready to go live, see Deployment below for more details.
### Deploying to Vercel
This template can also be deployed to Vercel for free. You can get started by choosing the Vercel DB adapter during the setup of the template or by manually installing and configuring it:
```bash
pnpm add @payloadcms/db-vercel-postgres
```
```ts
// payload.config.ts
import { vercelPostgresAdapter } from '@payloadcms/db-vercel-postgres'
export default buildConfig({
// ...
db: vercelPostgresAdapter({
pool: {
connectionString: process.env.POSTGRES_URL || '',
},
}),
// ...
```
We also support Vercel's blob storage:
```bash
pnpm add @payloadcms/storage-vercel-blob
```
```ts
// payload.config.ts
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
export default buildConfig({
// ...
plugins: [
vercelBlobStorage({
collections: {
[Media.slug]: true,
},
token: process.env.BLOB_READ_WRITE_TOKEN || '',
}),
],
// ...
```
There is also a simplified [one click deploy](https://github.com/payloadcms/payload/tree/templates/with-vercel-postgres) to Vercel should you need it.
### Self-hosting
Before deploying your app, you need to:
1. Ensure your app builds and serves in production. See [Production](#production) for more details.
2. You can then deploy Payload as you would any other Node.js or Next.js application either directly on a VPS, DigitalOcean's Apps Platform, via Coolify or more. More guides coming soon.
You can also deploy your app manually, check out the [deployment documentation](https://payloadcms.com/docs/production/deployment) for full details.
## Questions
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/payload) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).

View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app/(app)/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/utilities"
}
}

View File

@@ -0,0 +1,35 @@
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import { FlatCompat } from '@eslint/eslintrc'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const compat = new FlatCompat({
baseDirectory: __dirname,
})
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript'),
{
rules: {
'@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/no-empty-object-type': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{
vars: 'all',
args: 'after-used',
ignoreRestSiblings: false,
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^(_|ignore)',
},
],
},
},
]
export default eslintConfig

View File

@@ -0,0 +1,34 @@
import { withPayload } from '@payloadcms/next/withPayload'
import redirects from './redirects.js'
const NEXT_PUBLIC_SERVER_URL = process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000'
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
...[NEXT_PUBLIC_SERVER_URL /* 'https://example.com' */].map((item) => {
const url = new URL(item)
return {
hostname: url.hostname,
protocol: url.protocol.replace(':', ''),
}
}),
],
},
reactStrictMode: true,
redirects,
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
return webpackConfig
},
}
export default withPayload(nextConfig)

View File

@@ -0,0 +1,107 @@
{
"name": "ecommerce",
"version": "1.0.0",
"description": "Ecommerce template for Payload",
"license": "MIT",
"type": "module",
"scripts": {
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
"dev:prod": "cross-env NODE_OPTIONS=--no-deprecation rm -rf .next && pnpm build && pnpm start",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"ii": "cross-env NODE_OPTIONS=--no-deprecation pnpm --ignore-workspace install",
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
"lint:fix": "cross-env NODE_OPTIONS=--no-deprecation next lint --fix",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"reinstall": "cross-env NODE_OPTIONS=--no-deprecation rm -rf node_modules && rm pnpm-lock.yaml && pnpm --ignore-workspace install",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"stripe-webhooks": "stripe listen --forward-to localhost:3000/api/stripe/webhooks",
"test": "pnpm run test:int && pnpm run test:e2e",
"test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" pnpm exec playwright test",
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts"
},
"dependencies": {
"@payloadcms/admin-bar": "workspace:*",
"@payloadcms/db-mongodb": "workspace:*",
"@payloadcms/email-nodemailer": "workspace:*",
"@payloadcms/live-preview-react": "workspace:*",
"@payloadcms/next": "workspace:*",
"@payloadcms/plugin-ecommerce": "workspace:*",
"@payloadcms/plugin-form-builder": "workspace:*",
"@payloadcms/plugin-seo": "workspace:*",
"@payloadcms/richtext-lexical": "workspace:*",
"@payloadcms/translations": "workspace:*",
"@payloadcms/ui": "workspace:*",
"@radix-ui/react-accordion": "1.2.11",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@stripe/react-stripe-js": "^3",
"@stripe/stripe-js": "^4.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cross-env": "^7.0.3",
"date-fns": "^4.1.0",
"dotenv": "^8.2.0",
"embla-carousel-auto-scroll": "^8.1.5",
"embla-carousel-react": "^8.5.2",
"geist": "^1.3.0",
"jsonwebtoken": "9.0.1",
"lucide-react": "^0.477.0",
"next": "^15.5.4",
"next-themes": "0.4.6",
"payload": "workspace:*",
"prism-react-renderer": "^2.3.1",
"qs-esm": "^7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "7.54.1",
"sharp": "0.32.6",
"sonner": "^1.7.2",
"stripe": "18.5.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@next/eslint-plugin-next": "^15.5.4",
"@playwright/test": "1.50.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/postcss": "4.0.12",
"@tailwindcss/typography": "^0.5.12",
"@testing-library/react": "16.3.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "22.5.4",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"@vercel/git-hooks": "^1.0.0",
"@vitejs/plugin-react": "4.5.2",
"eslint": "^9.16.0",
"eslint-config-next": "15.1.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"jsdom": "26.1.0",
"lint-staged": "^15.2.2",
"playwright": "1.50.0",
"playwright-core": "1.50.0",
"postcss": "^8.4.38",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.12",
"typescript": "5.7.2",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.3"
},
"engines": {
"node": "^18.20.2 || >=20.9.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"sharp"
]
}
}

View File

@@ -0,0 +1,41 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
import 'dotenv/config'
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests/e2e',
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 3 : 1,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'pnpm dev',
reuseExistingServer: true,
url: 'http://localhost:3000',
},
})

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}

View File

@@ -0,0 +1,20 @@
const redirects = async () => {
const internetExplorerRedirect = {
destination: '/ie-incompatible.html',
has: [
{
type: 'header',
key: 'user-agent',
value: '(.*Trident.*)', // all ie browsers
},
],
permanent: false,
source: '/:path((?!ie-incompatible.html$).*)', // all pages except the incompatibility page
}
const redirects = [internetExplorerRedirect]
return redirects
}
export default redirects

View File

@@ -0,0 +1,9 @@
import type { Access } from 'payload'
import { checkRole } from '@/access/utilities'
export const adminOnly: Access = ({ req: { user } }) => {
if (user) return checkRole(['admin'], user)
return false
}

View File

@@ -0,0 +1,9 @@
import type { FieldAccess } from 'payload'
import { checkRole } from '@/access/utilities'
export const adminOnlyFieldAccess: FieldAccess = ({ req: { user } }) => {
if (user) return checkRole(['admin'], user)
return false
}

View File

@@ -0,0 +1,19 @@
import type { Access } from 'payload'
import { checkRole } from '@/access/utilities'
export const adminOrCustomerOwner: Access = ({ req: { user } }) => {
if (user && checkRole(['admin'], user)) {
return true
}
if (user?.id) {
return {
customer: {
equals: user.id,
},
}
}
return false
}

View File

@@ -0,0 +1,15 @@
import type { Access } from 'payload'
import { checkRole } from '@/access/utilities'
export const adminOrPublishedStatus: Access = ({ req: { user } }) => {
if (user && checkRole(['admin'], user)) {
return true
}
return {
_status: {
equals: 'published',
},
}
}

Some files were not shown because too many files have changed in this diff Show More