Browse Source

feat(docs): Add Multi-vendor marketplace guide

Michael Bromley 2 years ago
parent
commit
3a457fc5c8
29 changed files with 329 additions and 379 deletions
  1. 10 5
      docs/content/developer-guide/channels/index.md
  2. 183 0
      docs/content/developer-guide/multi-vendor-marketplaces/_index.md
  3. 1 1
      docs/data/build.json
  4. 8 0
      packages/core/src/config/shipping-method/default-shipping-line-assignment-strategy.ts
  5. 3 1
      packages/core/src/entity/administrator/administrator.entity.ts
  6. 13 0
      packages/core/src/entity/channel/channel.entity.ts
  7. 1 1
      packages/core/src/entity/role/role.entity.ts
  8. 2 1
      packages/core/src/entity/seller/seller.entity.ts
  9. 8 0
      packages/core/src/entity/shipping-line/shipping-line.entity.ts
  10. 5 0
      packages/core/src/entity/stock-location/stock-location.entity.ts
  11. 3 4
      packages/core/src/service/services/order.service.ts
  12. 6 317
      packages/dev-server/dev-config.ts
  13. 19 0
      packages/dev-server/example-plugins/multivendor-plugin/api/api-extensions.ts
  14. 3 3
      packages/dev-server/example-plugins/multivendor-plugin/api/mv.resolver.ts
  15. 0 0
      packages/dev-server/example-plugins/multivendor-plugin/config/mv-order-process.ts
  16. 3 0
      packages/dev-server/example-plugins/multivendor-plugin/config/mv-order-seller-strategy.ts
  17. 0 0
      packages/dev-server/example-plugins/multivendor-plugin/config/mv-payment-handler.ts
  18. 0 0
      packages/dev-server/example-plugins/multivendor-plugin/config/mv-shipping-eligibility-checker.ts
  19. 9 0
      packages/dev-server/example-plugins/multivendor-plugin/config/mv-shipping-line-assignment-strategy.ts
  20. 0 0
      packages/dev-server/example-plugins/multivendor-plugin/constants.ts
  21. 5 5
      packages/dev-server/example-plugins/multivendor-plugin/multivendor.plugin.ts
  22. 0 0
      packages/dev-server/example-plugins/multivendor-plugin/payment/mv-connect-sdk.ts
  23. 18 9
      packages/dev-server/example-plugins/multivendor-plugin/service/mv.service.ts
  24. 11 0
      packages/dev-server/example-plugins/multivendor-plugin/types.ts
  25. 0 12
      packages/dev-server/test-plugins/multivendor-plugin/api/api-extensions.ts
  26. 0 4
      packages/dev-server/test-plugins/multivendor-plugin/types.ts
  27. 11 0
      scripts/docs/docgen-utils.ts
  28. 4 4
      scripts/docs/generate-typescript-docs.ts
  29. 3 12
      scripts/docs/typescript-docs-parser.ts

+ 10 - 5
docs/content/developer-guide/channels/index.md

@@ -7,10 +7,10 @@ showtoc: true
 
 Channels are a feature of Vendure which allows multiple sales channels to be represented in a single Vendure instance. A Channel allows you to:
 
-* Set a channel-specific currency, tax and shipping defaults
+* Set a channel-specific currency, language, tax and shipping defaults
 * Assign only specific Products to the Channel (with Channel-specific prices)
 * Create Administrator roles limited to the Channel
-* Assign only specific Promotions, Collections, ShippingMethods to the Channel
+* Assign only specific StockLocations, Assets, Facets, Collections, Promotions, ShippingMethods & PaymentMethods to the Channel
 * Have Orders and Customers associated with specific Channels.
 
 Every Vendure server always has a **default Channel**, which contains _all_ entities. Subsequent channels can then contain a subset of the above entities.
@@ -22,19 +22,20 @@ Use-cases of Channels include:
 * Multi-region stores, where there is a distinct website for each territory with its own available inventory, pricing, tax and shipping rules.
 * Creating distinct rules and inventory for different sales channels such as Amazon.
 * Specialized stores offering a subset of the main inventory.
+* Implementing [multi-vendor marketplace]({{< relref "multi-vendor-marketplaces" >}}) applications.
 
 ## Channels, Currencies & Prices
 
-Each Channel has an associated **currencyCode** property, which sets the currency for all monetary values in that channel.
+Each Channel has a set of `availableCurrencyCodes`, and one of these is designated as the `defaultCurrencyCode`, which sets the currency for all monetary values in that channel.
 
 {{< figure src="channels_currencies_diagram.png" >}}
 
-Internally, there is a one-to-many relation from [ProductVariant]({{< relref "product-variant" >}}) to [ProductVariantPrice]({{< relref "product-variant-price" >}}). So the ProductVariant does _not_ hold a price for the product - this is actually stored on the ProductVariantPrice entity, and there will be one for each Channel to which the ProductVariant has been assigned.
+Internally, there is a one-to-many relation from [ProductVariant]({{< relref "product-variant" >}}) to [ProductVariantPrice]({{< relref "product-variant-price" >}}). So the ProductVariant does _not_ hold a price for the product - this is actually stored on the ProductVariantPrice entity, and there will be at least one for each Channel to which the ProductVariant has been assigned.
 
 {{< figure src="channels_prices_diagram.png" >}}
 
 {{< alert "warning" >}}
-**Note:** in the diagram above that the ProductVariant is **always assigned to the default Channel**, and thus will have a price in the default channel too. Likewise, the default Channel also has a currencyCode.
+**Note:** in the diagram above that the ProductVariant is **always assigned to the default Channel**, and thus will have a price in the default channel too. Likewise, the default Channel also has a defaultCurrencyCode.
 {{< /alert >}}
 
 ### Use-case: single shop
@@ -57,6 +58,10 @@ In this case, you can create the entire inventory in the default Channel and the
 **Note:** When creating a new Product & ProductVariants inside a sub-Channel, it will also **always get assigned to the default Channel**. If your sub-Channel uses a different currency from the default Channel, you should be aware that in the default Channel, that ProductVariant will be assigned the **same price** as it has in the sub-Channel. If the currency differs between the Channels, you need to make sure to set the correct price in the default Channel if you are exposing it to Customers via a storefront. 
 {{< /alert >}}
 
+### Use-case: Multi-vendor marketplace
+
+This is the most advanced use of channels. For a detailed guide to this use-case, see our [Multi-vendor marketplace guide]({{< relref "multi-vendor-marketplaces" >}}).
+
 
 ## How to set the channel when using the GraphQL API
 

+ 183 - 0
docs/content/developer-guide/multi-vendor-marketplaces/_index.md

@@ -0,0 +1,183 @@
+---
+title: "Multi-vendor Marketplaces"
+showtoc: true
+---
+
+# Multi-vendor Marketplaces
+
+Vendure v2.0 introduced a number of changes and new APIs to enable developers to build multi-vendor marketplace apps.
+
+This is a type of application in which multiple sellers are able to list products, and then customers can create orders containing products from one or more of these sellers. Well-known examples include Amazon, Ebay, Etsy and Airbnb.
+
+This guide introduces the major concepts & APIs you will need to understand in order to implement your own multi-vendor marketplace application. 
+
+## Multi-vendor plugin
+
+All the concepts presented here have been implemented in an example multi-vendor plugin. The guides here will refer to specific parts of this plugin which you should consult to get a full understanding of how an implementation would look.
+
+{{< alert warning >}}
+**Note:** the example multi-vendor plugin is for educational purposes only, and for the sake of clarity leaves out several parts that would be required in a production-ready solution, such as email verification and setup of a real payment solution.
+{{< /alert >}}
+
+## Sellers, Channels & Roles
+
+The core of Vendure's multi-vendor support is Channels. Read the [Channels guide]({{< relref "/developer-guide/channels" >}}) to get a more detailed understanding of how they work.
+
+Each Channel is assigned to a [Seller]({{< relref "seller" >}}), which is another term for the vendor who is selling things in our marketplace.
+
+So the first thing to do is to implement a way to create a new Channel and Seller.
+
+In the multi-vendor plugin, we have defined a new mutation in the Shop API which allows a new seller to register on our marketplace:
+
+```graphql
+mutation RegisterSeller {
+  registerNewSeller(input: {
+    shopName: "Bob's Parts",
+    seller: {
+      firstName: "Bob"
+      lastName: "Dobalina"
+      emailAddress: "bob@bobs-parts.com"
+      password: "test",
+    }
+  }) {
+    id
+    code
+    token
+  }
+}
+```
+
+Executing the `registerNewSeller` mutation does the following:
+
+- Create a new [Seller]({{< relref "seller" >}}) representing the shop "Bob's Parts"
+- Create a new [Channel]({{< relref "channel" >}}) and associate it with the new Seller
+- Create a [Role]({{< relref "role" >}}) & [Administrator]({{< relref "administrator" >}}) for Bob to access his shop admin account
+- Create a [ShippingMethod]({{< relref "shipping-method" >}}) for Bob's shop
+- Create a [StockLocation]({{< relref "stock-location" >}}) for Bob's shop
+
+Bob can now log in to the Admin UI using the provided credentials and begin creating products to sell!
+
+
+## Assigning OrderLines to the correct Seller
+
+In order to correctly split the Order later, we need to assign each added OrderLine to the correct Seller. This is done with the [OrderSellerStrategy]({{< relref "order-seller-strategy" >}}) API, and specifically the `setOrderLineSellerChannel()` method.
+
+The following logic will run any time the `addItemToOrder` mutation is executed from our storefront:
+
+```TypeScript
+export class MultivendorSellerStrategy implements OrderSellerStrategy {
+  // other properties omitted for brevity   
+    
+  async setOrderLineSellerChannel(ctx: RequestContext, orderLine: OrderLine) {
+    await this.entityHydrator.hydrate(ctx, orderLine.productVariant, { relations: ['channels'] });
+    const defaultChannel = await this.channelService.getDefaultChannel();
+  
+    // If a ProductVariant is assigned to exactly 2 Channels, then one is the default Channel
+    // and the other is the seller's Channel.
+    if (orderLine.productVariant.channels.length === 2) {
+      const sellerChannel = orderLine.productVariant.channels.find(
+        c => !idsAreEqual(c.id, defaultChannel.id),
+      );
+      if (sellerChannel) {
+        return sellerChannel;
+      }
+    }
+  }
+}
+```
+
+The end result is that each OrderLine in the Order will have its `sellerChannelId` property set to the correct Channel for the Seller.
+
+## Shipping
+
+When it comes time to choose a ShippingMethod for the Order, we need to ensure that the customer can only choose from the ShippingMethods which are supported by the Seller. To do this, we need to implement a [ShippingEligibilityChecker]({{< relref "shipping-eligibility-checker" >}}) which will filter the available ShippingMethods based on the `sellerChannelId` properties of the OrderLines.
+
+Here's how we do it in the example plugin:
+
+```TypeScript
+export const multivendorShippingEligibilityChecker = new ShippingEligibilityChecker({
+  // other properties omitted for brevity   
+    
+  check: async (ctx, order, args, method) => {
+    await entityHydrator.hydrate(ctx, method, { relations: ['channels'] });
+    await entityHydrator.hydrate(ctx, order, { relations: ['lines.sellerChannel'] });
+    const sellerChannel = method.channels.find(c => c.code !== DEFAULT_CHANNEL_CODE);
+    if (!sellerChannel) {
+      return false;
+    }
+    for (const line of order.lines) {
+      if (idsAreEqual(line.sellerChannelId, sellerChannel.id)) {
+        return true;
+      }
+    }
+    return false;
+  },
+});
+```
+
+In the storefront, when it comes time to assign ShippingMethods to the Order, we need to ensure that
+every OrderLine is covered by a valid ShippingMethod. We pass the ids of the eligible ShippingMethods to the `setOrderShippingMethod` mutation:
+
+```graphql
+mutation SetShippingMethod($ids: [ID!]!) {
+  setOrderShippingMethod(shippingMethodId: $ids) {
+    ... on Order {
+      id
+      state
+      # ...etc
+    }
+    ... on ErrorResult {
+      errorCode
+      message
+    }
+  }
+}
+```
+
+Now we need a way to assign the correct method to each line in an Order. This is done with the [ShippingLineAssignmentStrategy]({{< relref "shipping-line-assignment-strategy" >}}) API.
+
+We will again be relying on the `sellerChannelId` property of the OrderLines to determine which ShippingMethod to assign to each line. Here's how we do it in the example plugin:
+
+```TypeScript
+export class MultivendorShippingLineAssignmentStrategy implements ShippingLineAssignmentStrategy {
+  // other properties omitted for brevity   
+    
+  async assignShippingLineToOrderLines(ctx: RequestContext, shippingLine: ShippingLine, order: Order) {
+    // First we need to ensure the required relations are available
+    // to work with.
+    const defaultChannel = await this.channelService.getDefaultChannel();
+    await this.entityHydrator.hydrate(ctx, shippingLine, { relations: ['shippingMethod.channels'] });
+    const { channels } = shippingLine.shippingMethod;
+  
+    // We assume that, if a ShippingMethod is assigned to exactly 2 Channels,
+    // then one is the default Channel and the other is the seller's Channel.
+    if (channels.length === 2) {
+      const sellerChannel = channels.find(c => !idsAreEqual(c.id, defaultChannel.id));
+      if (sellerChannel) {
+        // Once we have established the seller's Channel, we can filter the OrderLines
+        // that belong to that Channel. The `sellerChannelId` was previously established
+        // in the `OrderSellerStrategy.setOrderLineSellerChannel()` method.
+        return order.lines.filter(line => idsAreEqual(line.sellerChannelId, sellerChannel.id));
+      }
+    }
+    return order.lines;
+  }
+}
+```
+
+## Splitting orders & payment
+
+When it comes to payments, there are many different ways that a multi-vendor marketplace might want to handle this. For example, the marketplace may collect all payments and then later disburse the funds to the Sellers. Or the marketplace may allow each Seller to connect their own payment gateway and collect payments directly.
+
+In the example plugin, we have implemented a simplified version of a service like [Stripe Connect](https://stripe.com/connect), whereby each Seller has a `connectedAccountId` (we auto-generate a random string for the example when registering the Seller). When configuring the plugin we also specify a "platform fee" percentage, which is the percentage of the total Order value which the marketplace will collect as a fee. The remaining amount is then split between the Sellers.
+
+The [OrderSellerStrategy]({{< relref "order-seller-strategy" >}}) API contains two methods which are used to first split the Order from a single order into one _Aggregate Order_ and multiple _Seller Orders_, and then to calculate the platform fee for each of the Seller Orders:
+
+- `OrderSellerStrategy.splitOrder`: Splits the OrderLines and ShippingLines of the Order into multiple groups, one for each Seller.
+- `OrderSellerStrategy.afterSellerOrdersCreated`: This method is run on every Seller Order created after the split, and we can use this to assign the platform fees to the Seller Order.
+
+## Custom OrderProcess
+
+Finally, we need a custom [OrderProcess]({{< relref "order-process" >}}) which will help keep the state of the resulting Aggregate Order and its Seller Orders in sync. For example, we want to make sure that the Aggregate Order cannot be transitioned to the `Shipped` state unless all of its Seller Orders are also in the `Shipped` state.
+
+Conversely, we can automatically set the state of the Aggregate Order to `Shipped` once all of its Seller Orders are in the `Shipped` state.

+ 1 - 1
docs/data/build.json

@@ -1,4 +1,4 @@
 {
   "version": "2.0.0-beta.3",
-  "commit": "90a914bf5"
+  "commit": "73fce94bf"
 }

+ 8 - 0
packages/core/src/config/shipping-method/default-shipping-line-assignment-strategy.ts

@@ -5,6 +5,14 @@ import { ShippingLine } from '../../entity/shipping-line/shipping-line.entity';
 
 import { ShippingLineAssignmentStrategy } from './shipping-line-assignment-strategy';
 
+/**
+ * @description
+ * This is the default {@link ShippingLineAssignmentStrategy} which simply assigns all OrderLines to the
+ * ShippingLine, and is suitable for the most common scenario of a single shipping method per Order.
+ *
+ * @since 2.0.0
+ * @docsCategory shipping
+ */
 export class DefaultShippingLineAssignmentStrategy implements ShippingLineAssignmentStrategy {
     assignShippingLineToOrderLines(
         ctx: RequestContext,

+ 3 - 1
packages/core/src/entity/administrator/administrator.entity.ts

@@ -9,7 +9,9 @@ import { User } from '../user/user.entity';
 
 /**
  * @description
- * An administrative user who has access to the admin ui.
+ * An administrative user who has access to the Admin UI and Admin API. The
+ * specific permissions of the Administrator are determined by the assigned
+ * {@link Role}s.
  *
  * @docsCategory entities
  */

+ 13 - 0
packages/core/src/entity/channel/channel.entity.ts

@@ -13,6 +13,19 @@ import { Zone } from '../zone/zone.entity';
  * A Channel represents a distinct sales channel and configures defaults for that
  * channel.
  *
+ * * Set a channel-specific currency, language, tax and shipping defaults
+ * * Assign only specific Products to the Channel (with Channel-specific prices)
+ * * Create Administrator roles limited to the Channel
+ * * Assign only specific StockLocations, Assets, Facets, Collections, Promotions, ShippingMethods & PaymentMethods to the Channel
+ * * Have Orders and Customers associated with specific Channels.
+ *
+ * In Vendure, Channels have a number of different uses, such as:
+ *
+ * * Multi-region stores, where there is a distinct website for each territory with its own available inventory, pricing, tax and shipping rules.
+ * * Creating distinct rules and inventory for different sales channels such as Amazon.
+ * * Specialized stores offering a subset of the main inventory.
+ * * Implementing multi-vendor marketplace applications.
+ *
  * @docsCategory entities
  */
 @Entity()

+ 1 - 1
packages/core/src/entity/role/role.entity.ts

@@ -9,7 +9,7 @@ import { Channel } from '../channel/channel.entity';
 /**
  * @description
  * A Role represents a collection of permissions which determine the authorization
- * level of a {@link User}.
+ * level of a {@link User} on a given set of {@link Channel}s.
  *
  * @docsCategory entities
  */

+ 2 - 1
packages/core/src/entity/seller/seller.entity.ts

@@ -8,7 +8,8 @@ import { CustomSellerFields } from '../custom-entity-fields';
 
 /**
  * @description
- * An administrative user who has access to the admin ui.
+ * A Seller represents the person or organization who is selling the goods on a given {@link Channel}.
+ * By default, a single-channel Vendure installation will have a single default Seller.
  *
  * @docsCategory entities
  */

+ 8 - 0
packages/core/src/entity/shipping-line/shipping-line.entity.ts

@@ -12,6 +12,14 @@ import { Money } from '../money.decorator';
 import { Order } from '../order/order.entity';
 import { ShippingMethod } from '../shipping-method/shipping-method.entity';
 
+/**
+ * @description
+ * A ShippingLine is created when a {@link ShippingMethod} is applied to an {@link Order}.
+ * It contains information about the price of the shipping method, any discounts that were
+ * applied, and the resulting tax on the shipping method.
+ *
+ * @docsCategory entities
+ */
 @Entity()
 export class ShippingLine extends VendureEntity {
     constructor(input?: DeepPartial<ShippingLine>) {

+ 5 - 0
packages/core/src/entity/stock-location/stock-location.entity.ts

@@ -11,6 +11,11 @@ import { CustomStockLocationFields } from '../custom-entity-fields';
  * @description
  * A StockLocation represents a physical location where stock is held. For example, a warehouse or a shop.
  *
+ * When the stock of a {@link ProductVariant} is adjusted, the adjustment is applied to a specific StockLocation,
+ * and the stockOnHand of that ProductVariant is updated accordingly. When there are multiple StockLocations
+ * configured, the {@link StockLocationStrategy} is used to determine which StockLocation should be used for
+ * a given operation.
+ *
  * @docsCategory entities
  */
 @Entity()

+ 3 - 4
packages/core/src/service/services/order.service.ts

@@ -916,13 +916,12 @@ export class OrderService {
             await this.connection
                 .getRepository(ctx, OrderLine)
                 .createQueryBuilder('line')
-                .update({ shippingLine })
+                .update({ shippingLineId: shippingLine.id })
                 .whereInIds(orderLinesForShippingLine.map(l => l.id))
                 .execute();
         }
-
-        await this.connection.getRepository(ctx, Order).save(order, { reload: false });
-        await this.applyPriceAdjustments(ctx, order);
+        const updatedOrder = await this.getOrderOrThrow(ctx, orderId);
+        await this.applyPriceAdjustments(ctx, updatedOrder);
         return this.connection.getRepository(ctx, Order).save(order);
     }
 

+ 6 - 317
packages/dev-server/dev-config.ts

@@ -20,7 +20,7 @@ import { compileUiExtensions } from '@vendure/ui-devkit/compiler';
 import path from 'path';
 import { DataSourceOptions } from 'typeorm';
 
-import { MultivendorPlugin } from './test-plugins/multivendor-plugin/multivendor.plugin';
+import { MultivendorPlugin } from './example-plugins/multivendor-plugin/multivendor.plugin';
 
 /**
  * Config settings used during development
@@ -62,327 +62,16 @@ export const devConfig: VendureConfig = {
         paymentMethodHandlers: [dummyPaymentHandler],
     },
 
-    customFields: {
-        Product: [
-            {
-                name: 'searchKeywords',
-                label: [{ languageCode: LanguageCode.en, value: 'Search keywords' }],
-                type: 'string',
-                defaultValue: '',
-                public: false,
-            },
-            {
-                name: 'noIndex',
-                label: [{ languageCode: LanguageCode.en, value: 'Do not allow crawlers to index' }],
-                type: 'boolean',
-                defaultValue: false,
-                public: true,
-                ui: { tab: 'SEO' },
-            },
-        ],
-        ProductVariant: [
-            {
-                name: 'weight',
-                type: 'int',
-                defaultValue: 0,
-                nullable: false,
-                min: 0,
-                step: 1,
-                public: true,
-                label: [{ languageCode: LanguageCode.en, value: 'Weight' }],
-                ui: { component: 'number-form-input', suffix: 'g' },
-            },
-            {
-                name: 'gtin',
-                type: 'string',
-                nullable: true,
-                public: true,
-                label: [{ languageCode: LanguageCode.en, value: 'GTIN (barcode)' }],
-            },
-        ],
-    },
-
-    /* customFields: {
-        ProductVariant: [
-            {
-                name: 'weight',
-                type: 'int',
-                defaultValue: 0,
-                nullable: false,
-                min: 0,
-                step: 1,
-                public: true,
-                label: [{ languageCode: LanguageCode.en, value: 'Weight' }],
-                ui: { component: 'number-form-input', suffix: 'g' },
-            },
-            {
-                name: 'rrp',
-                type: 'int',
-                nullable: true,
-                min: 0,
-                step: 1,
-                public: true,
-                label: [{ languageCode: LanguageCode.en, value: 'RRP' }],
-                ui: { component: 'currency-form-input' },
-            },
-            {
-                name: 'gtin',
-                type: 'string',
-                nullable: true,
-                public: true,
-                label: [{ languageCode: LanguageCode.en, value: 'GTIN (barcode)' }],
-            },
-            {
-                name: 'additionalInformation',
-                type: 'text',
-                nullable: true,
-                public: true,
-                label: [{ languageCode: LanguageCode.en, value: 'Additional Information' }],
-                ui: { component: 'json-editor-form-input' },
-            },
-        ],
-        Product: [
-            {
-                name: 'searchKeywords',
-                label: [{ languageCode: LanguageCode.en, value: 'Search keywords' }],
-                type: 'string',
-                defaultValue: '',
-                public: false,
-            },
-            {
-                name: 'pageType',
-                label: [{ languageCode: LanguageCode.en, value: 'Page type' }],
-                type: 'string',
-                defaultValue: 'default',
-                public: true,
-                options: [
-                    { value: 'default', label: [{ languageCode: LanguageCode.en, value: 'Default' }] },
-                    {
-                        value: 'colour-chart',
-                        label: [{ languageCode: LanguageCode.en, value: 'Colour chart' }],
-                    },
-                    {
-                        value: 'select-menu',
-                        label: [{ languageCode: LanguageCode.en, value: 'Select menu' }],
-                    },
-                    { value: 'gift-card', label: [{ languageCode: LanguageCode.en, value: 'Gift card' }] },
-                ],
-                ui: { tab: 'Display' },
-            },
-            {
-                name: 'keyFeatures',
-                label: [{ languageCode: LanguageCode.en, value: 'Key features' }],
-                type: 'text',
-                public: true,
-                nullable: true,
-                ui: { component: 'rich-text-form-input' },
-            },
-            {
-                name: 'videoUrls',
-                label: [{ languageCode: LanguageCode.en, value: 'Video urls' }],
-                type: 'string',
-                list: true,
-                public: true,
-                nullable: false,
-                defaultValue: [],
-            },
-            {
-                name: 'variantOrdering',
-                label: [{ languageCode: LanguageCode.en, value: 'Variant ordering' }],
-                type: 'string',
-                defaultValue: 'default',
-                public: true,
-                options: [
-                    { value: 'default', label: [{ languageCode: LanguageCode.en, value: 'Default' }] },
-                    {
-                        value: 'alphabetical',
-                        label: [{ languageCode: LanguageCode.en, value: 'Alphabetical' }],
-                    },
-                    {
-                        value: 'brush-size',
-                        label: [{ languageCode: LanguageCode.en, value: 'Brush size (000, 00, 0, 1, ...)' }],
-                    },
-                    {
-                        value: 'dimension',
-                        label: [
-                            { languageCode: LanguageCode.en, value: 'Dimension (5" x 5", 10" x 12",...)' },
-                        ],
-                    },
-                ],
-                ui: { tab: 'Display' },
-            },
-            {
-                name: 'seoTitle',
-                type: 'localeString',
-                public: true,
-                label: [{ languageCode: LanguageCode.en, value: 'SEO Title' }],
-                nullable: true,
-                ui: { tab: 'SEO' },
-            },
-            {
-                name: 'seoDescription',
-                type: 'localeString',
-                public: true,
-                label: [{ languageCode: LanguageCode.en, value: 'SEO Description' }],
-                nullable: true,
-                ui: { tab: 'SEO', component: 'textarea-form-input' },
-            },
-            {
-                name: 'seoImage',
-                type: 'relation',
-                entity: Asset,
-                public: true,
-                label: [{ languageCode: LanguageCode.en, value: 'SEO Image' }],
-                nullable: true,
-                ui: { tab: 'SEO' },
-            },
-            {
-                name: 'boost',
-                type: 'int',
-                public: true,
-                defaultValue: 0,
-                min: 0,
-                max: 5,
-                label: [{ languageCode: LanguageCode.en, value: 'Boost in list pages' }],
-                nullable: true,
-            },
-            {
-                name: 'minimumOrderQuantity',
-                type: 'int',
-                public: true,
-                min: 0,
-                label: [{ languageCode: LanguageCode.en, value: 'Minimum order quantity' }],
-                description: [
-                    {
-                        languageCode: LanguageCode.en,
-                        value: 'If set, the customer must order at least this number of any variants of this product',
-                    },
-                ],
-                nullable: true,
-            },
-        ],
-        Collection: [
-            {
-                name: 'excludeFromNavMenu',
-                label: [{ languageCode: LanguageCode.en, value: 'Exclude from nav menu' }],
-                type: 'boolean',
-                defaultValue: false,
-                public: true,
-            },
-            {
-                name: 'excludeFromSubCollections',
-                label: [{ languageCode: LanguageCode.en, value: 'Exclude from sub collections' }],
-                type: 'boolean',
-                defaultValue: false,
-                public: true,
-            },
-            {
-                name: 'noIndex',
-                label: [{ languageCode: LanguageCode.en, value: 'Do not allow crawlers to index' }],
-                type: 'boolean',
-                defaultValue: false,
-                public: true,
-                ui: { tab: 'SEO' },
-            },
-            {
-                name: 'searchKeywords',
-                label: [{ languageCode: LanguageCode.en, value: 'Search keywords' }],
-                type: 'string',
-                defaultValue: '',
-                public: false,
-            },
-            {
-                name: 'layout',
-                type: 'string',
-                public: true,
-                nullable: false,
-                defaultValue: 'fullWidth',
-                label: [{ languageCode: LanguageCode.en, value: 'Layout mode' }],
-                options: [
-                    { value: 'default', label: [{ languageCode: LanguageCode.en, value: 'Default' }] },
-                    { value: 'fullWidth', label: [{ languageCode: LanguageCode.en, value: 'Full width' }] },
-                ],
-            },
-            {
-                name: 'seoTitle',
-                type: 'localeString',
-                public: true,
-                label: [{ languageCode: LanguageCode.en, value: 'SEO Meta Title' }],
-                nullable: true,
-                ui: { tab: 'SEO' },
-            },
-            {
-                name: 'seoDescription',
-                type: 'localeString',
-                public: true,
-                label: [{ languageCode: LanguageCode.en, value: 'SEO Meta Description' }],
-                nullable: true,
-                ui: { tab: 'SEO', component: 'textarea-form-input' },
-            },
-            {
-                name: 'seoImage',
-                type: 'relation',
-                entity: Asset,
-                public: true,
-                label: [{ languageCode: LanguageCode.en, value: 'SEO Meta Image' }],
-                nullable: true,
-                ui: { tab: 'SEO' },
-            },
-            {
-                name: 'extendedDescription',
-                type: 'text',
-                public: true,
-                label: [{ languageCode: LanguageCode.en, value: 'Extended description' }],
-                nullable: true,
-                ui: { tab: 'SEO', component: 'rich-text-form-input' },
-            },
-            {
-                name: 'isOfferCollection',
-                type: 'boolean',
-                public: true,
-                label: [{ languageCode: LanguageCode.en, value: 'Is offer collecion' }],
-                nullable: true,
-                defaultValue: false,
-                ui: { tab: 'Offers' },
-            },
-            {
-                name: 'offerName',
-                type: 'string',
-                public: true,
-                label: [{ languageCode: LanguageCode.en, value: 'Offer name' }],
-                nullable: true,
-                ui: { tab: 'Offers' },
-            },
-            {
-                name: 'offerDescription',
-                type: 'string',
-                public: true,
-                label: [{ languageCode: LanguageCode.en, value: 'Offer description' }],
-                nullable: true,
-                ui: { tab: 'Offers', component: 'rich-text-form-input' },
-            },
-            {
-                name: 'displayRrpDiscount',
-                type: 'boolean',
-                public: true,
-                label: [{ languageCode: LanguageCode.en, value: 'Display RRP discount' }],
-                description: [
-                    {
-                        languageCode: LanguageCode.en,
-                        value: 'When enabled, the RRP discount of included products will be displayed in the storefront',
-                    },
-                ],
-                nullable: true,
-                ui: { tab: 'Offers' },
-            },
-        ],
-    }, */
+    customFields: {},
     logger: new DefaultLogger({ level: LogLevel.Verbose }),
     importExportOptions: {
         importAssetsDir: path.join(__dirname, 'import-assets'),
     },
     plugins: [
-        // MultivendorPlugin,
+        MultivendorPlugin.init({
+            platformFeePercent: 10,
+            platformFeeSKU: 'FEE',
+        }),
         AssetServerPlugin.init({
             route: 'assets',
             assetUploadDir: path.join(__dirname, 'assets'),

+ 19 - 0
packages/dev-server/example-plugins/multivendor-plugin/api/api-extensions.ts

@@ -0,0 +1,19 @@
+import gql from 'graphql-tag';
+
+export const shopApiExtensions = gql`
+    input CreateSellerInput {
+        firstName: String!
+        lastName: String!
+        emailAddress: String!
+        password: String!
+    }
+
+    input RegisterSellerInput {
+        shopName: String!
+        seller: CreateSellerInput!
+    }
+
+    extend type Mutation {
+        registerNewSeller(input: RegisterSellerInput!): Channel
+    }
+`;

+ 3 - 3
packages/dev-server/test-plugins/multivendor-plugin/api/mv.resolver.ts → packages/dev-server/example-plugins/multivendor-plugin/api/mv.resolver.ts

@@ -1,8 +1,8 @@
 import { Args, Mutation, Resolver } from '@nestjs/graphql';
-import { CreateAdministratorInput } from '@vendure/common/lib/generated-types';
 import { Allow, Ctx, Permission, RequestContext, Transaction } from '@vendure/core';
 
 import { MultivendorService } from '../service/mv.service';
+import { CreateSellerInput } from '../types';
 
 @Resolver()
 export class MultivendorResolver {
@@ -10,10 +10,10 @@ export class MultivendorResolver {
 
     @Mutation()
     @Transaction()
-    @Allow(Permission.SuperAdmin)
+    @Allow(Permission.Public)
     registerNewSeller(
         @Ctx() ctx: RequestContext,
-        @Args() args: { input: { shopName: string; administrator: CreateAdministratorInput } },
+        @Args() args: { input: { shopName: string; seller: CreateSellerInput } },
     ) {
         return this.multivendorService.registerNewSeller(ctx, args.input);
     }

+ 0 - 0
packages/dev-server/test-plugins/multivendor-plugin/config/mv-order-process.ts → packages/dev-server/example-plugins/multivendor-plugin/config/mv-order-process.ts


+ 3 - 0
packages/dev-server/test-plugins/multivendor-plugin/config/mv-order-seller-strategy.ts → packages/dev-server/example-plugins/multivendor-plugin/config/mv-order-seller-strategy.ts

@@ -50,6 +50,9 @@ export class MultivendorSellerStrategy implements OrderSellerStrategy {
     async setOrderLineSellerChannel(ctx: RequestContext, orderLine: OrderLine) {
         await this.entityHydrator.hydrate(ctx, orderLine.productVariant, { relations: ['channels'] });
         const defaultChannel = await this.channelService.getDefaultChannel();
+
+        // If a ProductVariant is assigned to exactly 2 Channels, then one is the default Channel
+        // and the other is the seller's Channel.
         if (orderLine.productVariant.channels.length === 2) {
             const sellerChannel = orderLine.productVariant.channels.find(
                 c => !idsAreEqual(c.id, defaultChannel.id),

+ 0 - 0
packages/dev-server/test-plugins/multivendor-plugin/config/mv-payment-handler.ts → packages/dev-server/example-plugins/multivendor-plugin/config/mv-payment-handler.ts


+ 0 - 0
packages/dev-server/test-plugins/multivendor-plugin/config/mv-shipping-eligibility-checker.ts → packages/dev-server/example-plugins/multivendor-plugin/config/mv-shipping-eligibility-checker.ts


+ 9 - 0
packages/dev-server/test-plugins/multivendor-plugin/config/mv-shipping-line-assignment-strategy.ts → packages/dev-server/example-plugins/multivendor-plugin/config/mv-shipping-line-assignment-strategy.ts

@@ -4,6 +4,7 @@ import {
     idsAreEqual,
     Injector,
     Order,
+    OrderSellerStrategy,
     RequestContext,
     ShippingLine,
     ShippingLineAssignmentStrategy,
@@ -19,12 +20,20 @@ export class MultivendorShippingLineAssignmentStrategy implements ShippingLineAs
     }
 
     async assignShippingLineToOrderLines(ctx: RequestContext, shippingLine: ShippingLine, order: Order) {
+        // First we need to ensure the required relations are available
+        // to work with.
         const defaultChannel = await this.channelService.getDefaultChannel();
         await this.entityHydrator.hydrate(ctx, shippingLine, { relations: ['shippingMethod.channels'] });
         const { channels } = shippingLine.shippingMethod;
+
+        // We assume that, if a ShippingMethod is assigned to exactly 2 Channels,
+        // then one is the default Channel and the other is the seller's Channel.
         if (channels.length === 2) {
             const sellerChannel = channels.find(c => !idsAreEqual(c.id, defaultChannel.id));
             if (sellerChannel) {
+                // Once we have established the seller's Channel, we can filter the OrderLines
+                // that belong to that Channel. The `sellerChannelId` was previously established
+                // in the `OrderSellerStrategy.setOrderLineSellerChannel()` method.
                 return order.lines.filter(line => idsAreEqual(line.sellerChannelId, sellerChannel.id));
             }
         }

+ 0 - 0
packages/dev-server/test-plugins/multivendor-plugin/constants.ts → packages/dev-server/example-plugins/multivendor-plugin/constants.ts


+ 5 - 5
packages/dev-server/test-plugins/multivendor-plugin/multivendor.plugin.ts → packages/dev-server/example-plugins/multivendor-plugin/multivendor.plugin.ts

@@ -12,7 +12,7 @@ import {
     VendurePlugin,
 } from '@vendure/core';
 
-import { adminApiExtensions } from './api/api-extensions';
+import { shopApiExtensions } from './api/api-extensions';
 import { MultivendorResolver } from './api/mv.resolver';
 import { multivendorOrderProcess } from './config/mv-order-process';
 import { MultivendorSellerStrategy } from './config/mv-order-seller-strategy';
@@ -49,12 +49,11 @@ import { MultivendorPluginOptions } from './types';
  * mutation RegisterSeller {
  *   registerNewSeller(input: {
  *     shopName: "Bob's Parts",
- *     administrator: {
+ *     seller {
  *       firstName: "Bob"
  *       lastName: "Dobalina"
  *       emailAddress: "bob@bobs-parts.com"
  *       password: "test",
- *       roleIds: []
  *     }
  *   }) {
  *     id
@@ -70,6 +69,7 @@ import { MultivendorPluginOptions } from './types';
  * - Create a new Channel and associate it with the new Seller
  * - Create a Role & Administrator for Bob to access his shop admin account
  * - Create a ShippingMethod for Bob's shop
+ * - Create a StockLocation for Bob's shop
  *
  * Bob can then go and sign in to the Admin UI using the provided emailAddress & password credentials, and start
  * creating some products.
@@ -146,8 +146,8 @@ import { MultivendorPluginOptions } from './types';
             new MultivendorShippingLineAssignmentStrategy();
         return config;
     },
-    adminApiExtensions: {
-        schema: adminApiExtensions,
+    shopApiExtensions: {
+        schema: shopApiExtensions,
         resolvers: [MultivendorResolver],
     },
     providers: [

+ 0 - 0
packages/dev-server/test-plugins/multivendor-plugin/payment/mv-connect-sdk.ts → packages/dev-server/example-plugins/multivendor-plugin/payment/mv-connect-sdk.ts


+ 18 - 9
packages/dev-server/test-plugins/multivendor-plugin/service/mv.service.ts → packages/dev-server/example-plugins/multivendor-plugin/service/mv.service.ts

@@ -15,10 +15,13 @@ import {
     SellerService,
     ShippingMethod,
     ShippingMethodService,
+    StockLocation,
+    StockLocationService,
     TaxSetting,
 } from '@vendure/core';
 
 import { multivendorShippingEligibilityChecker } from '../config/mv-shipping-eligibility-checker';
+import { CreateSellerInput } from '../types';
 
 @Injectable()
 export class MultivendorService {
@@ -29,14 +32,13 @@ export class MultivendorService {
         private channelService: ChannelService,
         private shippingMethodService: ShippingMethodService,
         private configService: ConfigService,
+        private stockLocationService: StockLocationService,
     ) {}
 
-    async registerNewSeller(
-        ctx: RequestContext,
-        input: { shopName: string; administrator: CreateAdministratorInput },
-    ) {
+    async registerNewSeller(ctx: RequestContext, input: { shopName: string; seller: CreateSellerInput }) {
         const channel = await this.createSellerChannelRoleAdmin(ctx, input);
         await this.createSellerShippingMethod(ctx, input.shopName, channel);
+        await this.createSellerStockLocation(ctx, input.shopName, channel);
         return channel;
     }
 
@@ -89,9 +91,16 @@ export class MultivendorService {
         ]);
     }
 
+    private async createSellerStockLocation(ctx: RequestContext, shopName: string, sellerChannel: Channel) {
+        const stockLocation = await this.stockLocationService.create(ctx, {
+            name: `${shopName} Warehouse`,
+        });
+        await this.channelService.assignToChannels(ctx, StockLocation, stockLocation.id, [sellerChannel.id]);
+    }
+
     private async createSellerChannelRoleAdmin(
         ctx: RequestContext,
-        input: { shopName: string; administrator: CreateAdministratorInput },
+        input: { shopName: string; seller: CreateSellerInput },
     ) {
         const defaultChannel = await this.channelService.getDefaultChannel(ctx);
         const shopCode = normalizeString(input.shopName, '-');
@@ -146,10 +155,10 @@ export class MultivendorService {
             ],
         });
         const administrator = await this.administratorService.create(ctx, {
-            firstName: input.administrator.firstName,
-            lastName: input.administrator.lastName,
-            emailAddress: input.administrator.emailAddress,
-            password: input.administrator.password,
+            firstName: input.seller.firstName,
+            lastName: input.seller.lastName,
+            emailAddress: input.seller.emailAddress,
+            password: input.seller.password,
             roleIds: [role.id],
         });
         return channel;

+ 11 - 0
packages/dev-server/example-plugins/multivendor-plugin/types.ts

@@ -0,0 +1,11 @@
+export interface MultivendorPluginOptions {
+    platformFeePercent: number;
+    platformFeeSKU: string;
+}
+
+export interface CreateSellerInput {
+    firstName: string;
+    lastName: string;
+    emailAddress: string;
+    password: string;
+}

+ 0 - 12
packages/dev-server/test-plugins/multivendor-plugin/api/api-extensions.ts

@@ -1,12 +0,0 @@
-import gql from 'graphql-tag';
-
-export const adminApiExtensions = gql`
-    input RegisterSellerInput {
-        shopName: String!
-        administrator: CreateAdministratorInput!
-    }
-
-    extend type Mutation {
-        registerNewSeller(input: RegisterSellerInput!): Channel
-    }
-`;

+ 0 - 4
packages/dev-server/test-plugins/multivendor-plugin/types.ts

@@ -1,4 +0,0 @@
-export interface MultivendorPluginOptions {
-    platformFeePercent: number;
-    platformFeeSKU: string;
-}

+ 11 - 0
scripts/docs/docgen-utils.ts

@@ -25,6 +25,17 @@ export function titleCase(input: string): string {
         .join(' ');
 }
 
+export function normalizeForUrlPart<T extends string | undefined>(input: T): T {
+    if (input == null) {
+        return input;
+    }
+    return input
+        .replace(/([a-z])([A-Z])/g, '$1-$2')
+        .replace(/[^a-zA-Z0-9-_/]/g, ' ')
+        .replace(/\s+/g, '-')
+        .toLowerCase() as T;
+}
+
 /**
  * Delete all generated docs found in the outputPath.
  */

+ 4 - 4
scripts/docs/generate-typescript-docs.ts

@@ -3,7 +3,7 @@ import fs from 'fs-extra';
 import klawSync from 'klaw-sync';
 import path, { extname } from 'path';
 
-import { deleteGeneratedDocs } from './docgen-utils';
+import { deleteGeneratedDocs, normalizeForUrlPart } from './docgen-utils';
 import { TypeMap } from './typescript-docgen-types';
 import { TypescriptDocsParser } from './typescript-docs-parser';
 import { TypescriptDocsRenderer } from './typescript-docs-renderer';
@@ -78,9 +78,9 @@ function generateTypescriptDocs(config: DocsSectionConfig[], isWatchMode: boolea
         for (const page of docsPages) {
             const { category, fileName, declarations } = page;
             for (const declaration of declarations) {
-                const pathToTypeDoc = `${outputPath}/${category ? category + '/' : ''}${
-                    fileName === '_index' ? '' : fileName
-                }#${toHash(declaration.title)}`;
+                const pathToTypeDoc = `${outputPath}/${
+                    category ? category.map(part => normalizeForUrlPart(part)).join('/') + '/' : ''
+                }${fileName === '_index' ? '' : fileName}#${toHash(declaration.title)}`;
                 globalTypeMap.set(declaration.title, pathToTypeDoc);
             }
         }

+ 3 - 12
scripts/docs/typescript-docs-parser.ts

@@ -4,6 +4,7 @@ import ts, { HeritageClause, JSDocTag, SyntaxKind } from 'typescript';
 
 import { notNullOrUndefined } from '../../packages/common/src/shared-utils';
 
+import { normalizeForUrlPart } from './docgen-utils';
 import {
     DocsPage,
     MemberInfo,
@@ -54,7 +55,7 @@ export class TypescriptDocsParser {
                 if (existingPage) {
                     existingPage.declarations.push(declaration);
                 } else {
-                    const normalizedTitle = this.kebabCase(pageTitle);
+                    const normalizedTitle = normalizeForUrlPart(pageTitle);
                     const categoryLastPart = declaration.category.split('/').pop();
                     const fileName = normalizedTitle === categoryLastPart ? '_index' : normalizedTitle;
                     pages.set(pageTitle, {
@@ -396,7 +397,7 @@ export class TypescriptDocsParser {
         this.parseTags(statement, {
             docsCategory: comment => (category = comment || ''),
         });
-        return this.kebabCase(category);
+        return normalizeForUrlPart(category);
     }
 
     /**
@@ -436,16 +437,6 @@ export class TypescriptDocsParser {
         return '\n\n*Example*\n\n' + example.replace(/\r/g, '');
     }
 
-    private kebabCase<T extends string | undefined>(input: T): T {
-        if (input == null) {
-            return input;
-        }
-        return input
-            .replace(/([a-z])([A-Z])/g, '$1-$2')
-            .replace(/\s+/g, '-')
-            .toLowerCase() as T;
-    }
-
     /**
      * TypeScript from v3.5.1 interprets all '@' tokens in a tag comment as a new tag. This is a problem e.g.
      * when a plugin includes in its description some text like "install the @vendure/some-plugin package". Here,