Просмотр исходного кода

fix(docs): Remove duplicate H1 headings from MDX files

Title should only be set in frontmatter, not as H1 heading in content.
David Höck 6 дней назад
Родитель
Сommit
3bc8cf2ce8
41 измененных файлов с 5032 добавлено и 7 удалено
  1. 48 0
      docs/docs/guides/deployment/getting-data-into-production.mdx
  2. 73 0
      docs/docs/guides/deployment/horizontal-scaling.mdx
  3. 138 0
      docs/docs/guides/deployment/production-configuration/index.mdx
  4. 191 0
      docs/docs/guides/deployment/using-docker.mdx
  5. 224 0
      docs/docs/guides/developer-guide/configuration/index.mdx
  6. 113 0
      docs/docs/guides/developer-guide/db-subscribers/index.mdx
  7. 75 0
      docs/docs/guides/developer-guide/logging/index.mdx
  8. 211 0
      docs/docs/guides/developer-guide/migrating-from-v1/breaking-api-changes.mdx
  9. 37 0
      docs/docs/guides/developer-guide/migrating-from-v1/database-migration.mdx
  10. 39 0
      docs/docs/guides/developer-guide/migrating-from-v1/index.mdx
  11. 28 0
      docs/docs/guides/developer-guide/migrating-from-v1/storefront-migration.mdx
  12. 0 7
      docs/docs/guides/developer-guide/settings-store/index.mdx
  13. 117 0
      docs/docs/guides/developer-guide/stand-alone-scripts/index.mdx
  14. 252 0
      docs/docs/guides/developer-guide/testing/index.mdx
  15. 77 0
      docs/docs/guides/developer-guide/updating/index.mdx
  16. 218 0
      docs/docs/guides/developer-guide/uploading-files/index.mdx
  17. 330 0
      docs/docs/guides/extending-the-admin-ui/creating-detail-views/index.mdx
  18. 1900 0
      docs/docs/guides/extending-the-dashboard/migration/index.mdx
  19. 195 0
      docs/docs/guides/how-to/multi-vendor-marketplaces/index.mdx
  20. 229 0
      docs/docs/guides/storefront/order-workflow/index.mdx
  21. 11 0
      docs/docs/reference/admin-ui-api/index.mdx
  22. 14 0
      docs/docs/reference/graphql-api/_index.mdx
  23. 11 0
      docs/docs/reference/graphql-api/admin/_index.mdx
  24. 11 0
      docs/docs/reference/graphql-api/shop/_index.mdx
  25. 11 0
      docs/docs/reference/typescript-api/_index.mdx
  26. 25 0
      docs/docs/user-guide/catalog/collections.mdx
  27. 32 0
      docs/docs/user-guide/catalog/facets.mdx
  28. 33 0
      docs/docs/user-guide/catalog/products.mdx
  29. 28 0
      docs/docs/user-guide/customers/index.mdx
  30. 9 0
      docs/docs/user-guide/index.mdx
  31. 40 0
      docs/docs/user-guide/localization/index.mdx
  32. 26 0
      docs/docs/user-guide/orders/draft-orders.mdx
  33. 63 0
      docs/docs/user-guide/orders/orders.mdx
  34. 42 0
      docs/docs/user-guide/promotions/index.mdx
  35. 32 0
      docs/docs/user-guide/settings/administrators-roles.mdx
  36. 16 0
      docs/docs/user-guide/settings/channels.mdx
  37. 10 0
      docs/docs/user-guide/settings/countries-zones.mdx
  38. 10 0
      docs/docs/user-guide/settings/global-settings.mdx
  39. 30 0
      docs/docs/user-guide/settings/payment-methods.mdx
  40. 53 0
      docs/docs/user-guide/settings/shipping-methods.mdx
  41. 30 0
      docs/docs/user-guide/settings/taxes.mdx

+ 48 - 0
docs/docs/guides/deployment/getting-data-into-production.mdx

@@ -0,0 +1,48 @@
+---
+title: "Getting data into production"
+showtoc: true
+weight: 4
+---
+
+Once you have set up your production deployment, you'll need some way to get your products and other data into the system.
+
+The main tasks will be:
+
+1. Creation of the database schema
+2. Importing initial data like roles, tax rates, countries etc.
+3. Importing catalog data like products, variants, options, facets
+4. Importing other data used by your application
+
+## Creating the database schema
+
+The first item - creation of the schema - can be automatically handled by TypeORM's `synchronize` feature. Switching it on for the initial
+run will automatically create the schema. This can be done by using an environment variable:
+
+```ts title="src/vendure-config.ts"
+import { VendureConfig } from '@vendure/core';
+
+export const config: VendureConfig = {
+    // ...
+    dbConnectionOptions: {
+        type: 'postgres',
+        // highlight-next-line
+        synchronize: process.env.DB_SYNCHRONIZE,
+        host: process.env.DB_HOST,
+        port: process.env.DB_PORT,
+        username: process.env.DB_USER,
+        password: process.env.DB_PASSWORD,
+        database: process.env.DB_DATABASE,
+    },
+    // ...
+};
+```
+
+Set the `DB_SYNCHRONIZE` variable to `true` on first start, and then after the schema is created, set it to `false`.
+
+## Importing initial & catalog data
+
+Importing initial and catalog data can be handled by Vendure `populate()` helper function - see the [Importing Product Data guide](/guides/developer-guide/importing-data/).
+
+## Importing other data
+
+Any kinds of data not covered by the `populate()` function can be imported using a custom script, which can use any Vendure service or service defined by your custom plugins to populate data in any way you like. See the [Stand-alone scripts guide](/guides/developer-guide/stand-alone-scripts/).

+ 73 - 0
docs/docs/guides/deployment/horizontal-scaling.mdx

@@ -0,0 +1,73 @@
+---
+title: "Horizontal scaling"
+showtoc: true
+weight: 2
+---
+
+"Horizontal scaling" refers to increasing the performance capacity of your application by running multiple instances.
+
+This type of scaling has two main advantages:
+
+1. It can enable increased throughput (requests/second) by distributing the incoming requests between multiple instances.
+2. It can increase resilience because if a single instance fails, the other instances will still be able to service requests.
+
+As discussed in the [Server resource requirements guide](/guides/deployment/server-resource-requirements), horizontal scaling can be the most cost-effective way of deploying your Vendure server due to the single-threaded nature of Node.js.
+
+In Vendure, both the server and the worker can be scaled horizontally. Scaling the server will increase the throughput of the GraphQL APIs, whereas scaling the worker can increase the speed with which the job queue is processed by allowing more jobs to be run in parallel.
+
+## Multi-instance configuration
+
+In order to run Vendure in a multi-instance configuration, there are some important configuration changes you'll need to make. The key consideration in configuring Vendure for this scenario is to ensure that any persistent state is managed externally from the Node process, and is shared by all instances. Namely:
+
+* The JobQueue should be stored externally using the [DefaultJobQueuePlugin](/reference/typescript-api/job-queue/default-job-queue-plugin/) (which stores jobs in the database) or the [BullMQJobQueuePlugin](/reference/core-plugins/job-queue-plugin/bull-mqjob-queue-plugin) (which stores jobs in Redis), or some other custom JobQueueStrategy. **Note:** the BullMQJobQueuePlugin is much more efficient than the DefaultJobQueuePlugin, and is recommended for production applications.
+* An appropriate [CacheStrategy](/reference/typescript-api/cache/cache-strategy/) must be used which stores the cache externally. Both the [DefaultCachePlugin](/reference/typescript-api/cache/default-cache-plugin/) and the [RedisCachePlugin](/reference/typescript-api/cache/redis-cache-plugin/) are suitable 
+  for multi-instance setups. The DefaultCachePlugin uses the database to store the cache data, which is simple and effective, while the RedisCachePlugin uses a Redis server to store the cache data and can have better performance characteristics.
+* If you are on a version prior to v3.1, a custom [SessionCacheStrategy](/reference/typescript-api/auth/session-cache-strategy/) must be used which stores the session cache externally (such as in the database or Redis), since the default strategy stores the cache in-memory and will cause inconsistencies in multi-instance setups. [Example Redis-based SessionCacheStrategy](/reference/typescript-api/auth/session-cache-strategy/).
+  From v3.1 the session cache is handled by the underlying cache strategy, so you normally don't need to define a custom SessionCacheStrategy.
+* When using cookies to manage sessions, make sure all instances are using the _same_ cookie secret:
+    ```ts title="src/vendure-config.ts"
+    const config: VendureConfig = {
+      authOptions: {
+        cookieOptions: {
+          secret: 'some-secret'
+        }
+      }
+    }
+    ```
+* Channel and Zone data gets cached in-memory as this data is used in virtually every request. The cache time-to-live defaults to 30 seconds, which is probably fine for most cases, but it can be configured in the [EntityOptions](/reference/typescript-api/configuration/entity-options/#channelcachettl).
+
+## Using Docker or Kubernetes
+
+One way of implementing horizontal scaling is to use Docker to wrap your Vendure server & worker in a container, which can then be run as multiple instances.
+
+Some hosting providers allow you to provide a Docker image and will then run multiple instances of that image. Kubernetes can also be used to manage multiple instances
+of a Docker image.
+
+For a more complete guide, see the [Using Docker guide](/guides/deployment/using-docker).
+
+## Using PM2
+
+[PM2](https://pm2.keymetrics.io/) is a process manager which will spawn multiple instances of your server or worker, as well as re-starting any instances that crash. PM2 can be used on VPS hosts to manage multiple instances of Vendure without needing Docker or Kubernetes.
+
+PM2 must be installed on your server:
+
+```sh
+npm install pm2@latest -g
+```
+
+Your processes can then be run in [cluster mode](https://pm2.keymetrics.io/docs/usage/cluster-mode/) with the following command:
+
+```sh
+pm2 start ./dist/index.js -i 4
+```
+
+The above command will start a cluster of 4 instances. You can also instruct PM2 to use the maximum number of available CPUs with `-i max`.
+
+Note that if you are using pm2 inside a Docker container, you should use the `pm2-runtime` command:
+
+```dockerfile
+# ... your existing Dockerfile config
+RUN npm install pm2 -g
+
+CMD ["pm2-runtime", "app.js", "-i", "max"]
+```

+ 138 - 0
docs/docs/guides/deployment/production-configuration/index.mdx

@@ -0,0 +1,138 @@
+---
+title: 'Production configuration'
+showtoc: true
+weight: 0
+---
+
+This is a guide to the recommended configuration for a production Vendure application.
+
+## Environment variables
+
+Keep sensitive information or context-dependent settings in environment variables. In local development you can store the values in a `.env` file. For production, you should use the mechanism provided by your hosting platform to set the values for production.
+
+The default `@vendure/create` project scaffold makes use of environment variables already. For example:
+
+```ts
+const IS_DEV = process.env.APP_ENV === 'dev';
+```
+
+The `APP_ENV` environment variable can then be set using the admin dashboard of your hosting provider:
+
+![A typical UI for setting env vars](./env-var-ui.webp)
+
+If you are using [Docker or Kubernetes](/guides/deployment/using-docker), they include their own methods of setting environment variables.
+
+## Superadmin credentials
+
+Ensure you set the superadmin credentials to something other than the default of `superadmin:superadmin`. Use your hosting platform's environment variables to set a **strong** password for the Superadmin account.
+
+```ts
+import { VendureConfig } from '@vendure/core';
+
+export const config: VendureConfig = {
+  authOptions: {
+    tokenMethod: ['bearer', 'cookie'],
+    superadminCredentials: {
+      identifier: process.env.SUPERADMIN_USERNAME,
+      password: process.env.SUPERADMIN_PASSWORD,
+    },
+  },
+  // ...
+};
+```
+
+## API hardening
+
+It is recommended that you install and configure the [HardenPlugin](/reference/core-plugins/harden-plugin/) for all production deployments. This plugin locks down your schema (disabling introspection and field suggestions) and protects your Shop API against malicious queries that could otherwise overwhelm your server.
+
+Install the plugin: 
+
+```sh
+npm install @vendure/harden-plugin
+
+# or
+
+yarn add @vendure/harden-plugin
+```
+
+Then add it to your VendureConfig:
+
+```ts
+import { VendureConfig } from '@vendure/core';
+import { HardenPlugin } from '@vendure/harden-plugin';
+
+const IS_DEV = process.env.APP_ENV === 'dev';
+
+export const config: VendureConfig = {
+  // ...
+  plugins: [
+    HardenPlugin.init({
+      maxQueryComplexity: 500,
+      apiMode: IS_DEV ? 'dev' : 'prod',
+    }),
+    // ...
+  ]
+};
+```
+
+:::info
+For a detailed explanation of how to best configure this plugin, see the [HardenPlugin docs](/reference/core-plugins/harden-plugin/).
+:::
+
+## ID Strategy
+
+By default, Vendure uses auto-increment integer IDs as entity primary keys. While easier to work with in development, sequential primary keys can leak information such as the number of orders or customers in the system.
+
+For this reason you should consider using the UuidIdStrategy for production.
+
+```ts title="src/vendure-config.ts"
+import { UuidIdStrategy, VendureConfig } from '@vendure/core';
+
+export const config: VendureConfig = {
+    entityOptions: {
+        entityIdStrategy: new UuidIdStrategy(),
+    },
+    // ...
+}
+```
+
+Another option, if you wish to stick with integer IDs, is to create a custom [EntityIdStrategy](/reference/typescript-api/configuration/entity-id-strategy/) which uses the `encodeId()` and `decodeId()` methods to obfuscate the sequential nature of the ID.
+
+## Database Timezone
+
+Vendure internally treats all dates & times as UTC. However, you may sometimes run into issues where dates are offset by some fixed amount of hours. E.g. you place an order at 17:00, but it shows up in the Dashboard as being placed at 19:00. Typically, this is caused by the timezone of your database not being set to UTC.
+
+You can check the timezone in **MySQL/MariaDB** by executing:
+
+```SQL
+SELECT TIMEDIFF(NOW(), UTC_TIMESTAMP);
+```
+and you should expect to see `00:00:00`.
+
+In **Postgres**, you can execute:
+```SQL
+show timezone;
+```
+and you should expect to see `UTC` or `Etc/UTC`.
+
+## Trust proxy
+
+When deploying your Vendure application behind a reverse proxy (usually the case with most hosting services), consider configuring Express's `trust proxy` setting. This allows you to retrieve the original IP address from the `X-Forwarded-For` header, which proxies use to forward the client's IP address.
+
+You can set the `trustProxy` option in your `VendureConfig`:
+
+```ts
+import { VendureConfig } from '@vendure/core';
+
+export const config: VendureConfig = {
+    apiOptions: {
+        trustProxy: 1, // Trust the first proxy in front of your app
+    },
+};
+```
+
+For more details on configuring `trust proxy`, refer to the [Express documentation](https://expressjs.com/en/guide/behind-proxies.html).
+
+## Security Considerations
+
+Please read over the [Security](/guides/developer-guide/security) section of the Developer Guide for more information on how to secure your Vendure application.

+ 191 - 0
docs/docs/guides/deployment/using-docker.mdx

@@ -0,0 +1,191 @@
+---
+title: "Using docker"
+showtoc: true
+weight: 3
+---
+
+[Docker](https://docs.docker.com/) is a technology which allows you to run your Vendure application inside a [container](https://docs.docker.com/get-started/#what-is-a-container).
+The default installation with `@vendure/create` includes a sample Dockerfile:
+
+```dockerfile title="Dockerfile"
+FROM node:22
+
+WORKDIR /usr/src/app
+
+COPY package.json ./
+COPY package-lock.json ./ 
+RUN npm install --production
+COPY . .
+RUN npm run build
+```
+
+This Dockerfile can then be built into an "image" using:
+
+```sh
+docker build -t vendure .
+```
+
+This same image can be used to run both the Vendure server and the worker:
+
+```sh
+# Run the server
+docker run -dp 3000:3000 --name vendure-server vendure npm run start:server
+
+# Run the worker
+docker run -dp 3000:3000 --name vendure-worker vendure npm run start:worker
+```
+
+Here is a breakdown of the command used above:
+
+- `docker run` - run the image we created with `docker build`
+- `-dp 3000:3000` - the `-d` flag means to run in "detached" mode, so it runs in the background and does not take control of your terminal. `-p 3000:3000` means to expose port 3000 of the container (which is what Vendure listens on by default) as port 3000 on your host machine.
+- `--name vendure-server` - we give the container a human-readable name.
+- `vendure` - we are referencing the tag we set up during the build.
+- `npm run start:server` - this last part is the actual command that should be run inside the container.
+
+## Docker Compose
+
+Managing multiple docker containers can be made easier using [Docker Compose](https://docs.docker.com/compose/). In the below example, we use 
+the same Dockerfile defined above, and we also define a Postgres database to connect to:
+
+```yaml
+version: "3"
+services:
+  server:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    ports:
+      - 3000:3000
+    command: ["npm", "run", "start:server"]
+    volumes:
+      - /usr/src/app
+    environment:
+      DB_HOST: database
+      DB_PORT: 5432
+      DB_NAME: vendure
+      DB_USERNAME: postgres
+      DB_PASSWORD: password
+  worker:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    command: ["npm", "run", "start:worker"]
+    volumes:
+      - /usr/src/app
+    environment:
+      DB_HOST: database
+      DB_PORT: 5432
+      DB_NAME: vendure
+      DB_USERNAME: postgres
+      DB_PASSWORD: password
+  database:
+    image: postgres
+    volumes:
+      - /var/lib/postgresql/data
+    ports:
+      - 5432:5432
+    environment:
+      POSTGRES_PASSWORD: password
+      POSTGRES_DB: vendure
+```
+
+## Kubernetes
+
+[Kubernetes](https://kubernetes.io/) is used to manage multiple containerized applications. 
+This deployment starts the shop container we created above as both worker and server.
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: vendure-shop
+spec:
+  selector:
+    matchLabels:
+      app: vendure-shop
+  replicas: 1
+  template:
+    metadata:
+      labels:
+        app: vendure-shop
+    spec:
+      containers:
+        - name: server
+          image: vendure-shop:latest
+          command:
+            - node
+          args:
+            - "dist/index.js"
+          env:
+          # your env config here
+          ports:
+            - containerPort: 3000
+
+        - name: worker
+          image: vendure-shop:latest
+          imagePullPolicy: Always
+          command:
+            - node
+          args:
+            - "dist/index-worker.js"
+          env:
+          # your env config here
+          ports:
+            - containerPort: 3000
+```
+
+## Health/Readiness Checks
+
+If you wish to deploy with Kubernetes or some similar system, you can make use of the health check endpoints. 
+
+### Server
+
+This is a regular REST route (note: _not_ GraphQL), available at `/health`.
+
+```text 
+REQUEST: GET http://localhost:3000/health
+```
+ 
+```json
+{
+  "status": "ok",
+  "info": {
+    "database": {
+      "status": "up"
+    }
+  },
+  "error": {},
+  "details": {
+    "database": {
+      "status": "up"
+    }
+  }
+}
+```
+
+Health checks are built on the [Nestjs Terminus module](https://docs.nestjs.com/recipes/terminus). You can also add your own health checks by creating plugins that make use of the [HealthCheckRegistryService](/reference/typescript-api/health-check/health-check-registry-service/).
+
+### Worker
+
+Although the worker is not designed as an HTTP server, it contains a minimal HTTP server specifically to support HTTP health checks. To enable this, you need to call the `startHealthCheckServer()` method after bootstrapping the worker:
+
+```ts
+bootstrapWorker(config)
+    .then(worker => worker.startJobQueue())
+    .then(worker => worker.startHealthCheckServer({ port: 3020 }))
+    .catch(err => {
+        console.log(err);
+    });
+```
+This will make the `/health` endpoint available. When the worker instance is running, it will return the following:
+
+```text 
+REQUEST: GET http://localhost:3020/health
+```
+
+```json
+{
+  "status": "ok"
+}
+```

+ 224 - 0
docs/docs/guides/developer-guide/configuration/index.mdx

@@ -0,0 +1,224 @@
+---
+title: "Configuration"
+sidebar_position: 3
+---
+
+Every aspect of the Vendure server is configured via a single, central [`VendureConfig`](/reference/typescript-api/configuration/vendure-config/) object. This object is passed into the [`bootstrap`](/reference/typescript-api/common/bootstrap/) and [`bootstrapWorker`](/reference/typescript-api/worker/bootstrap-worker/) functions to start up the Vendure server and worker respectively.
+
+The `VendureConfig` object is organised into sections, grouping related settings together. For example, [`VendureConfig.apiOptions`](/reference/typescript-api/configuration/api-options/) contains all the config for the GraphQL APIs, whereas [`VendureConfig.authOptions`](/reference/typescript-api/auth/auth-options/) deals with authentication.
+
+## Important Configuration Settings
+
+In this guide, we will take a look at those configuration options needed for getting the server up and running.
+
+:::tip
+A description of every available configuration option can be found in the [`VendureConfig` reference docs](/reference/typescript-api/configuration/vendure-config/).
+:::
+
+### Specifying API hostname & port etc
+
+The [`VendureConfig.apiOptions`](/reference/typescript-api/configuration/api-options/) object is used to set the hostname, port, as well as other API-related concerns. Express middleware and Apollo Server plugins may also be specified here.
+
+Example:
+
+```ts title="src/vendure-config.ts"
+import { VendureConfig } from '@vendure/core';
+
+export const config: VendureConfig = {
+  apiOptions: {
+    hostname: 'localhost',
+    port: 3000,
+    adminApiPath: '/admin',
+    shopApiPath: '/shop',
+    middleware: [{
+      // add some Express middleware to the Shop API route
+      handler: timeout('5s'),
+      route: 'shop',
+    }]
+  },
+  // ...
+}
+```
+
+### Connecting to the database
+
+The database connection is configured with the `VendureConfig.dbConnectionOptions` object. This object is actually the [TypeORM DataSourceOptions object](https://typeorm.io/data-source-options) and is passed directly to TypeORM.
+
+Example:
+
+```ts title="src/vendure-config.ts"
+import { VendureConfig } from '@vendure/core';
+
+export const config: VendureConfig = {
+  dbConnectionOptions: {
+    type: 'postgres',
+    host: process.env.DB_HOST,
+    port: process.env.DB_PORT,
+    synchronize: false,
+    username: process.env.DB_USERNAME,
+    password: process.env.DB_PASSWORD,
+    database: 'vendure',
+    migrations: [path.join(__dirname, 'migrations/*.ts')],
+  },
+  // ...
+}
+```
+
+### Configuring authentication
+
+Authentication settings are configured with [`VendureConfig.authOptions`](/reference/typescript-api/auth/auth-options/). The most important setting here is whether the storefront client will use cookies or bearer tokens to manage user sessions. For more detail on this topic, see [the Managing Sessions guide](/guides/storefront/connect-api/#managing-sessions).
+
+The username and default password of the superadmin user can also be specified here. In production, it is advisable to use environment variables for these settings (see the following section on usage of environment variables).
+
+Example:
+
+```ts title="src/vendure-config.ts"
+import { VendureConfig } from '@vendure/core';
+
+export const config: VendureConfig = {
+  authOptions: {
+    tokenMethod: 'cookie',
+    requireVerification: true,
+    cookieOptions: {
+      secret: process.env.COOKIE_SESSION_SECRET,
+    },
+    superadminCredentials: {
+      identifier: process.env.SUPERADMIN_USERNAME,
+      password: process.env.SUPERADMIN_PASSWORD,
+    },
+  },
+  // ...
+}
+```
+
+## Working with the VendureConfig object
+
+Since the `VendureConfig` is just a JavaScript object, it can be managed and manipulated according to your needs. For example:
+
+### Using environment variables
+
+Environment variables can be used when you don't want to hard-code certain values which may change, e.g. depending on whether running locally, in staging or in production:
+
+```ts title="src/vendure-config.ts"
+import { VendureConfig } from '@vendure/core';
+
+export const config: VendureConfig = {
+  apiOptions: {
+    hostname: process.env.HOSTNAME,
+    port: process.env.PORT,
+  }
+  // ...
+};
+```
+
+They are also useful so that sensitive credentials do not need to be hard-coded and committed to source control:
+
+```ts title="src/vendure-config.ts"
+import { VendureConfig } from '@vendure/core';
+
+export const config: VendureConfig = {
+  dbConnectionOptions: {
+    type: 'postgres',
+    username: process.env.DB_USERNAME,
+    password: process.env.DB_PASSWORD,
+    database: 'vendure',
+  },
+  // ...
+}
+```
+
+When you create a Vendure project with `@vendure/create`, it comes with the [dotenv](https://www.npmjs.com/package/dotenv) package installed, which allows you to store environment variables in a `.env` file in the root of your project.
+
+To define new environment variables, you can add them to the `.env` file. For instance, if you are using a plugin that requires
+an API key, you can
+
+```txt title=".env"
+APP_ENV=dev
+COOKIE_SECRET=toh8soqdlj
+SUPERADMIN_USERNAME=superadmin
+SUPERADMIN_PASSWORD=superadmin
+// highlight-next-line
+MY_API_KEY=12345
+```
+
+In order to tell TypeScript about the existence of this new variable, you can add it to the `src/environment.d.ts` file:
+
+```ts title="src/environment.d.ts"
+export {};
+
+// Here we declare the members of the process.env object, so that we
+// can use them in our application code in a type-safe manner.
+declare global {
+    namespace NodeJS {
+        interface ProcessEnv {
+            APP_ENV: string;
+            COOKIE_SECRET: string;
+            SUPERADMIN_USERNAME: string;
+            SUPERADMIN_PASSWORD: string;
+            // highlight-next-line
+            MY_API_KEY: string;
+        }
+    }
+}
+````
+
+You can then use the environment variable in your config file:
+
+```ts title="src/vendure-config.ts"
+import { VendureConfig } from '@vendure/core';
+
+export const config: VendureConfig = {
+  plugins: [
+    MyPlugin.init({
+      apiKey: process.env.MY_API_KEY,
+    }),
+  ],
+  // ...
+}
+```
+
+:::info
+
+In production, the way you manage environment variables will depend on your hosting provider. Read more about this in our [Production Configuration guide](/guides/deployment/production-configuration/).
+
+:::
+
+
+
+### Splitting config across files
+
+If the config object grows too large, you can split it across several files. For example, the `plugins` array in a real-world project can easily grow quite big:
+
+```ts title="src/vendure-config-plugins.ts"
+import { AssetServerPlugin, DefaultJobQueuePlugin, VendureConfig } from '@vendure/core';
+import { ElasticsearchPlugin } from '@vendure/elasticsearch-plugin';
+import { EmailPlugin } from '@vendure/email-plugin';
+import { CustomPlugin } from './plugins/custom-plugin';
+
+export const plugins: VendureConfig['plugins'] = [
+  CustomPlugin,
+  AssetServerPlugin.init({
+      route: 'assets',
+      assetUploadDir: path.join(__dirname, 'assets'),
+      port: 5002,
+  }),
+  DefaultJobQueuePlugin,
+  ElasticsearchPlugin.init({
+      host: 'localhost',
+      port: 9200,
+  }),
+  EmailPlugin.init({
+    // ...lots of lines of config
+  }),
+];
+```
+
+```ts title="src/vendure-config.ts"
+import { VendureConfig } from '@vendure/core';
+import { plugins } from './vendure-config-plugins';
+
+export const config: VendureConfig = {
+  plugins,
+  // ...
+}
+```

+ 113 - 0
docs/docs/guides/developer-guide/db-subscribers/index.mdx

@@ -0,0 +1,113 @@
+---
+title: "Database subscribers"
+---
+
+TypeORM allows us to define [subscribers](https://typeorm.io/listeners-and-subscribers#what-is-a-subscriber). With a subscriber, we can listen to specific entity events and take actions based on inserts, updates, deletions and more.
+
+If you need lower-level access to database changes that you get with the [Vendure EventBus system](/reference/typescript-api/events/event-bus/), TypeORM subscribers can be useful.
+
+## Simple subscribers
+
+The simplest way to register a subscriber is to pass it to the `dbConnectionOptions.subscribers` array:
+
+```ts title="src/plugins/my-plugin/product-subscriber.ts"
+import { Product, VendureConfig } from '@vendure/core';
+import { EntitySubscriberInterface, EventSubscriber, UpdateEvent } from 'typeorm';
+
+@EventSubscriber()
+export class ProductSubscriber implements EntitySubscriberInterface<Product> {
+  listenTo() {
+    return Product;
+  }
+  
+  beforeUpdate(event: UpdateEvent<Product>) {
+    console.log(`BEFORE PRODUCT UPDATED: `, event.entity);
+  }
+}
+```
+
+```ts title="src/vendure-config.ts"
+import { VendureConfig } from '@vendure/core';
+import { ProductSubscriber } from './plugins/my-plugin/product-subscriber';
+
+// ...
+export const config: VendureConfig = {
+  dbConnectionOptions: {
+    // ...
+    subscribers: [ProductSubscriber],
+  }
+}
+```
+
+The limitation of this method is that the `ProductSubscriber` class cannot make use of dependency injection, since it is not known to the underlying NestJS application and is instead instantiated by TypeORM directly.
+
+If you need to make use of providers in your subscriber class, you'll need to use the following pattern.
+
+## Injectable subscribers
+
+By defining the subscriber as an injectable provider, and passing it to a Vendure plugin, you can take advantage of Nest's dependency injection inside the subscriber methods.
+
+```ts title="src/plugins/my-plugin/product-subscriber.ts"
+import {
+  PluginCommonModule,
+  Product,
+  TransactionalConnection,
+  VendureConfig,
+  VendurePlugin,
+} from '@vendure/core';
+import { Injectable } from '@nestjs/common';
+import { EntitySubscriberInterface, EventSubscriber, UpdateEvent } from 'typeorm';
+import { MyService } from './services/my.service';
+
+@Injectable()
+@EventSubscriber()
+export class ProductSubscriber implements EntitySubscriberInterface<Product> {
+    constructor(private connection: TransactionalConnection,
+                private myService: MyService) {
+        // This is how we can dynamically register the subscriber
+        // with TypeORM
+        connection.rawConnection.subscribers.push(this);
+    }
+
+    listenTo() {
+        return Product;
+    }
+
+    async beforeUpdate(event: UpdateEvent<Product>) {
+        console.log(`BEFORE PRODUCT UPDATED: `, event.entity);
+        // Now we can make use of our injected provider
+        await this.myService.handleProductUpdate(event);
+    }
+}
+```
+
+```ts title="src/plugins/my-plugin/my.plugin.ts"
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    providers: [ProductSubscriber, MyService],
+})
+class MyPlugin {
+}
+```
+
+```ts title="src/vendure-config.ts"
+// ...
+export const config: VendureConfig = {
+    dbConnectionOptions: {
+        // We no longer need to pass the subscriber here
+        // subscribers: [ProductSubscriber],
+    },
+    plugins: [
+        MyPlugin,
+    ],
+}
+```
+
+## Troubleshooting subscribers
+
+An important factor when working with TypeORM subscribers is that they are very low-level and require some understanding of the Vendure schema.
+
+For example consider the `ProductSubscriber` above. If an admin changes a product's name in the Dashboard, this subscriber **will not fire**. The reason is that the `name` property is actually stored on the `ProductTranslation` entity, rather than on the `Product` entity.
+
+So if your subscribers do not seem to work as expected, check your database schema and make sure you are really targeting the correct entity which has the property that you are interested in.
+

+ 75 - 0
docs/docs/guides/developer-guide/logging/index.mdx

@@ -0,0 +1,75 @@
+---
+title: "Logging"
+showtoc: true
+---
+
+Logging allows you to see what is happening inside the Vendure server. It is useful for debugging and for monitoring the health of the server in production.
+
+In Vendure, logging is configured using the `logger` property of the [VendureConfig](/reference/typescript-api/configuration/vendure-config/#logger) object. The logger must implement the [VendureLogger](/reference/typescript-api/logger/vendure-logger) interface.
+
+:::info
+To implement a custom logger, see the [Implementing a custom logger](/reference/typescript-api/logger/#implementing-a-custom-logger) guide.
+:::
+
+## Log levels
+
+Vendure uses 5 log levels, in order of increasing severity:
+
+| Level     | Description                                                                                              |
+|-----------|----------------------------------------------------------------------------------------------------------|
+| `Debug`   | The most verbose level, used for debugging purposes. The output can be very noisy at this level          |
+| `Verbose` | More information than the Info level, but less than `Debug`                                              |
+| `Info`    | General information about the normal running of the server                                               |
+| `Warning` | Issues which might need attention or action, but which do not prevent the server from continuing to run. |
+| `Error`   | Errors which should be investigated and handled; something has gone wrong.                               |
+
+
+## DefaultLogger
+
+Vendure ships with a [DefaultLogger](/reference/typescript-api/logger/default-logger) which logs to the console (process.stdout). It can be configured with the desired log level:
+
+```ts title="src/vendure-config.ts"
+import { DefaultLogger, VendureConfig } from '@vendure/core';
+
+const config: VendureConfig = {
+    // ...
+    logger: new DefaultLogger({ level: LogLevel.Debug }),
+};
+```
+
+## Logging database queries
+
+To log database queries, set the `logging` property of the `dbConnectionOptions` as well as setting the logger to `Debug` level.
+
+```ts title="src/vendure-config.ts"
+import { DefaultLogger, LogLevel, VendureConfig } from '@vendure/core';
+
+const config: VendureConfig = {
+    // ...
+    logger: new DefaultLogger({ level: LogLevel.Debug }),
+    dbConnectionOptions: {
+        // ... etc
+        logging: true,
+        
+        // You can also specify which types of DB events to log:
+        // logging: ['error', 'warn', 'schema', 'query', 'info', 'log'],
+    },
+};
+```
+
+More information about the `logging` option can be found in the [TypeORM logging documentation](https://typeorm.io/logging).
+
+## Logging in your own plugins
+
+When you extend Vendure by creating your own plugins, it's a good idea to log useful information about what your plugin is doing. To do this, you need to import the [Logger](/reference/typescript-api/logger/) class from `@vendure/core` and use it in your plugin:
+
+```ts title="src/plugins/my-plugin/my.plugin.ts"
+import { Logger } from '@vendure/core';
+
+// It is customary to define a logger context for your plugin
+// so that the log messages can be easily identified.
+const loggerCtx = 'MyPlugin';
+
+// somewhere in your code
+Logger.info(`My plugin is doing something!`, loggerCtx);
+```

+ 211 - 0
docs/docs/guides/developer-guide/migrating-from-v1/breaking-api-changes.mdx

@@ -0,0 +1,211 @@
+---
+title: "Breaking API Changes"
+sidebar_position: 3
+---
+
+## Breaks from updated dependencies
+
+### TypeScript
+- v2 is built on TypeScript **v4.9.5**. You should update your TypeScript version to match this. Doing so is quite likely to reveal new compiler errors (as is usual with TypeScript minor release updates).
+- If you are using `ts-node`, update it to the latest version
+- If you are targeting `ES2022` or `ESNEXT` in your `tsconfig.json`, you'll need to set `"useDefineForClassFields": false`. See [this issue](https://github.com/vendurehq/vendure/issues/2099) for more context.
+
+### Apollo Server & GraphQL
+If you have any custom ApolloServerPlugins, the plugin methods must now return a Promise. Example:
+
+```diff
+export class TranslateErrorsPlugin implements ApolloServerPlugin {
+   constructor(private i18nService: I18nService) {}
+
+-  requestDidStart(): GraphQLRequestListener {
++  async requestDidStart(): Promise<GraphQLRequestListener> {
+     return {
+-      willSendResponse: requestContext => {
++      willSendResponse: async requestContext => {
+         const { errors, context } = requestContext;
+         if (errors) {
+           (requestContext.response as any).errors = errors.map(err => {
+             return this.i18nService.translateError(context.req, err as GraphQLError) as any;
+           });
+         }
+       },
+     };
+   }
+}
+```
+
+With the update to GraphQL v16, you might run into issues with other packages in the GraphQL ecosystem that also depend on the `graphql` package, such as `graphql-code-generator`. In this case these packages will also need to be updated.
+
+For instance, if you are using the "typescript-compatibility" plugin to generate namespaced types, you'll need to drop this, as it is [no longer maintained](https://the-guild.dev/blog/whats-new-in-graphql-codegen-v2#typescript-compatibility).
+
+### TypeORM
+TypeORM 0.3.x introduced a large number of breaking changes. For a complete guide, see the [TypeORM v0.3.0 release notes](https://github.com/typeorm/typeorm/releases/tag/0.3.0). 
+
+Here are the main API changes you'll likely need to make:
+
+- You can no longer compare to `null`, you need to use the new `IsNull()` helper:
+    ```diff
+    + import { IsNull } from 'typeorm';
+
+    - .find({ where: { deletedAt: null } })
+    + .find({ where: { deletedAt: IsNull() } })
+    ```
+- The `findOne()` method returns `null` rather than `undefined` if a record is not found.
+- The `findOne()` method no longer accepts an id argument. Lookup based on id must be done with a `where` clause:
+    ```diff
+    - .findOne(variantId)
+    + .findOne({ where: { id: variantId } })
+    ```
+- Where clauses must use an entity id rather than passing an entity itself:
+    ```diff
+    - .find({ where: { user } })
+    + .find({ where: { user: { id: user.id } } })
+    ```
+- The `findByIds()` method has been deprecated. Use the new `In` helper instead:
+    ```diff
+    + import { In } from 'typeorm';
+
+    - .findByIds(ids)
+    + .find({ where: { id: In(ids) } })
+    ```
+
+## Vendure TypeScript API Changes
+
+### Custom Order / Fulfillment / Payment processes
+
+In v2, the hard-coded states & transition logic for the Order, Fulfillment and Payment state machines has been extracted from the core services and instead reside in a default `OrderProcess`, `FulfillmentProcess` and `PaymentProcess` object. This allows you to _fully_ customize these flows without having to work around the assumptions & logic implemented by the default processes.
+
+What this means is that if you are defining a custom process, you'll now need to explicitly add the default process to the array.
+
+```diff
++ import { defaultOrderProcess } from '@vendure/core';
+
+orderOptions: {
+-  process: [myCustomOrderProcess],
++  process: [defaultOrderProcess, myCustomOrderProcess],
+}
+```
+
+Also note that `shippingOptions.customFulfillmentProcess` and `paymentOptions.customPaymentProcess` are both now renamed to `process`. The old names are still usable but are deprecated.
+
+### OrderItem no longer exists
+
+As a result of [#1981](https://github.com/vendurehq/vendure/issues/1981), the `OrderItem` entity no longer exists. The function and data of `OrderItem` is now transferred to `OrderLine`. As a result, the following APIs which previously used OrderItem arguments have now changed:
+
+- `FulfillmentHandler`
+- `ChangedPriceHandlingStrategy`
+- `PromotionItemAction`
+- `TaxLineCalculationStrategy`
+
+If you have implemented any of these APIs, you'll need to check each one, remove the `OrderItem` argument from any methods that are using it,
+and update any logic as necessary.
+
+You may also be joining the OrderItem relation in your own TypeORM queries, so you'll need to check for code like this:
+
+```diff
+const order = await this.connection
+  .getRepository(Order)
+  .createQueryBuilder('order')
+  .leftJoinAndSelect('order.lines', 'line')
+- .leftJoinAndSelect('line.items', 'items')
+```
+
+or 
+
+```diff
+const order = await this.connection
+  .getRepository(Order)
+  .findOne(ctx, orderId, {
+-    relations: ['lines', 'lines.items'],
++    relations: ['lines'],
+  });
+```
+
+### ProductVariant stock changes
+
+With [#1545](https://github.com/vendurehq/vendure/issues/1545) we have changed the way we model stock levels in order to support multiple stock locations. This means that the `ProductVariant.stockOnHand` and `ProductVariant.stockAllocated` properties no longer exist on the `ProductVariant` entity in TypeScript.
+
+Instead, this information is now located at `ProductVariant.stockLevels`, which is an array of [StockLevel](/reference/typescript-api/entities/stock-level) entities.
+
+### New return type for Channel, TaxCategory & Zone lists
+
+- The `ChannelService.findAll()` method now returns a `PaginatedList<Channel>` instead of `Channel[]`. 
+- The `channels` GraphQL query now returns a `PaginatedList` rather than a simple array of Channels.
+- The `TaxCategoryService.findAll()` method now returns a `PaginatedList<TaxCategory>` instead of `TaxCategory[]`.
+- The `taxCategories` GraphQL query now returns a `PaginatedList` rather than a simple array of TaxCategories.
+- The `ZoneService.findAll()` method now returns a `PaginatedList<Zone>` instead of `Zone[]`. The old behaviour of `ZoneService.findAll()` (all Zones, cached for rapid access) can now be found under the new `ZoneService.getAllWithMembers()` method.
+- The `zones` GraphQL query now returns a `PaginatedList` rather than a simple array of Zones. 
+
+### Admin UI changes
+
+If you are using the `@vendure/ui-devkit` package to generate custom ui extensions, here are the breaking changes to be aware of:
+
+- As part of the major refresh to the Admin UI app, certain layout elements had be changed which can cause your custom routes to look bad. Wrapping all your custom pages in `<vdr-page-block>` (or `<div class="page-block">` if not built with Angular components) will improve things. There will soon be a comprehensive guide published on how to create seamless ui extensions that look just like the built-in screens.
+- If you use any of the scoped method of the Admin UI `DataService`, you might find that some no longer exist. They are now deprecated and will eventually be removed. Use the `dataService.query()` and `dataService.mutation()` methods only, passing your own GraphQL documents:
+   ```ts
+    // Old way
+   this.dataService.product.getProducts().single$.subscribe(...);
+   ```
+   ```ts
+    // New way
+   const GET_PRODUCTS = gql`
+     query GetProducts {
+       products {
+         items {
+           id
+           name
+           # ... etc
+         }
+       }
+     }
+   `;
+   this.dataService.query(GET_PRODUCTS).single$.subscribe(...);
+   ```
+- The Admin UI component `vdr-product-selector` has been renamed to `vdr-product-variant-selector` to more accurately represent what it does. If you are using `vdr-product-selector` if any ui extensions code, update it to use the new selector.
+
+### Other breaking API changes
+- **End-to-end tests using Jest** will likely run into issues due to our move towards using some dependencies that make use of ES modules. We have found the best solution to be to migrate tests over to [Vitest](https://vitest.dev), which can handle this and is also significantly faster than Jest. See the updated [Testing guide](/guides/developer-guide/testing) for instructions on getting started with Vitest.
+- Internal `ErrorResult` classes now take a single object argument rather than multiple args.
+- All monetary values are now represented in the GraphQL APIs with a new `Money` scalar type. If you use [graphql-code-generator](https://the-guild.dev/graphql/codegen), you'll want to tell it to treat this scalar as a number:
+    ```ts
+    import { CodegenConfig } from '@graphql-codegen/cli'
+ 
+    const config: CodegenConfig = {
+      schema: 'http://localhost:3000/shop-api',
+      documents: ['src/**/*graphql.ts'],
+      config: {
+        scalars: {
+          Money: 'number',
+        },
+      },
+      generates: {
+        // .. 
+      }
+    };
+    ```
+- A new `Region` entity has been introduced, which is a base class for `Country` and the new `Province` entity. The `Zone.members` property is now an array of `Region` rather than `Country`, since Zones may now be composed of both countries and provinces. If you have defined any custom fields on `Country`, you'll need to change it to `Region` in your custom fields config.
+- If you are using the **s3 storage strategy** of the AssetServerPlugin, it has been updated to use v3 of the AWS SDKs. This update introduces an [improved modular architecture to the AWS sdk](https://aws.amazon.com/blogs/developer/modular-packages-in-aws-sdk-for-javascript/), resulting in smaller bundle sizes. You need to install the `@aws-sdk/client-s3` & `@aws-sdk/lib-storage` packages, and can remove the `aws-sdk` package. If you are using it in combination with MinIO, you'll also need to rename a config property and provide a region:
+   ```diff
+   nativeS3Configuration: {
+      endpoint: 'http://localhost:9000',
+   -  s3ForcePathStyle: true,
+   +  forcePathStyle: true,
+      signatureVersion: 'v4',
+   +  region: 'eu-west-1',
+   }
+   ```
+- The **Stripe plugin** has been made channel aware. This means your api key and webhook secret are now stored in the database, per channel, instead of environment variables.
+   To migrate to v2 of the Stripe plugin from @vendure/payments you need to:
+  1. Remove the apiKey and webhookSigningSecret from the plugin initialization in vendure-config.ts:
+       ```diff
+       StripePlugin.init({
+       -  apiKey: process.env.YOUR_STRIPE_SECRET_KEY,
+       -  webhookSigningSecret: process.env.YOUR_STRIPE_WEBHOOK_SIGNING_SECRET,
+          storeCustomersInStripe: true,
+       }),
+       ```
+  2. Start the server and login as administrator. For each channel that you'd like to use Stripe payments, you need to create a payment method with payment handler Stripe payment and the apiKey and webhookSigningSecret belonging to that channel's Stripe account.
+- If you are using the **BullMQJobQueuePlugin**, the minimum Redis recommended version is 6.2.0.
+- The `WorkerHealthIndicator` which was deprecated in v1.3.0 has been removed, as well as the `jobQueueOptions.enableWorkerHealthCheck` config option.
+- The `CustomerGroupEntityEvent` (fired on creation, update or deletion of a CustomerGroup) has been renamed to `CustomerGroupEvent`, and the former `CustomerGroupEvent` (fired when Customers are added to or removed from a group) has been renamed to `CustomerGroupChangeEvent`.
+- We introduced the [plugin compatibility API](/guides/developer-guide/plugins/#step-7-specify-compatibility) to allow plugins to indicate what version of Vendure they are compatible with. To avoid bootstrap messages you should add this property to your plugins.

+ 37 - 0
docs/docs/guides/developer-guide/migrating-from-v1/database-migration.mdx

@@ -0,0 +1,37 @@
+---
+title: "Database Migration"
+sidebar_position: 2
+---
+
+Vendure v2 introduces a number of breaking changes to the database schema, some of which require quite complex migrations in order to preserve existing data. To make this process as smooth as possible, we have created a migration tool which will handle the hard parts for you!
+
+:::warning
+**Important!** It is _critical_ that you back up your production data prior to attempting this migration.
+
+**Note for MySQL/MariaDB users:** transactions for migrations are [not supported by these databases](https://dev.mysql.com/doc/refman/5.7/en/cannot-roll-back.html). This means that if the migration fails for some reason, the statements that have executed will not get rolled back, and your DB schema can be left in an inconsistent state from which is it can be hard to recover. Therefore, it is doubly critical that you have a good backup that you can easily restore prior to attempting this migration.
+:::
+
+1. Make sure all your Vendure packages to the latest v2 versions.
+2. Use your package manager to install the [v2 migration tool](https://github.com/vendurehq/v2-migration-tool): `npm install @vendure/migrate-v2`
+    - Note, if you run into the error `"Cannot find module '@ardatan/aggregate-error'"`, delete node_modules and the lockfile and reinstall.
+3. Add the `MigrationV2Plugin` to your plugins array:
+   ```ts
+   import { MigrationV2Plugin } from '@vendure/migrate-v2';
+   
+   //...
+   const config: VendureConfig = {
+     //..
+     plugins: [
+       MigrationV2Plugin,
+     ]
+   }
+   ```
+   The sole function of this plugin is to temporarily remove some "NOT NULL" constraints from certain columns, which allows us to run the next part of the migration.
+4. Generate a new migration file, `npm run migration:generate v2`
+5. Edit the newly-created migration file by following the comments in these examples: 
+    - [postgres](https://github.com/vendurehq/v2-migration-tool/blob/master/src/migrations/1686649098749-v201-postgres.ts)
+    - [mysql](https://github.com/vendurehq/v2-migration-tool/blob/master/src/migrations/1686655918823-v201-mysql.ts)
+
+   In your migrations files, you'll import the `vendureV2Migrations` from `@vendure/migrate-v2`.
+6. Run the migration with `npm run migration:run`.
+7. Upon successful migration, remove the `MigrationV2Plugin` from your plugins array, and generate _another_ migration. This one will add back the missing "NOT NULL" constraints now that all your data has been successfully migrated.

+ 39 - 0
docs/docs/guides/developer-guide/migrating-from-v1/index.mdx

@@ -0,0 +1,39 @@
+---
+title: "Migrating from v1"
+weight: 2
+sidebar_position: 1
+---
+
+This section contains guides for migrating from Vendure v1 to v2.
+
+There are a number of breaking changes between the two versions, which are due to a few major factors:
+
+1. To support new features such as multi-vendor marketplace APIs and multiple stock locations, we had to make some changes to the database schema and some of the internal APIs.
+2. We have updated all of our major dependencies to their latest versions. Some of these updates involve breaking changes in the dependencies themselves, and in those cases where you are using those dependencies directly (most notably TypeORM), you will need to make the corresponding changes to your code.
+3. We have removed some old APIs which were previously marked as "deprecated".
+
+## Migration steps
+
+Migration will consist of these main steps:
+
+1. **Update your Vendure dependencies** to the latest versions
+   ```diff
+   {
+     // ...
+     "dependencies": {
+   -    "@vendure/common": "1.9.7",
+   -    "@vendure/core": "1.9.7",
+   +    "@vendure/common": "2.0.0",
+   +    "@vendure/core": "2.0.0",
+        // etc.
+     },
+     "devDependencies": {
+   -    "typescript": "4.3.5",
+   +    "typescript": "4.9.5",
+        // etc.
+     }
+   }
+   ```
+2. **Migrate your database**. This is covered in detail in the [database migration section](/guides/developer-guide/migrating-from-v1/database-migration).
+3. **Update your custom code** (configuration, plugins, admin ui extensions) to handle the breaking changes. Details of these changes are covered in the [breaking API changes section](/guides/developer-guide/migrating-from-v1/breaking-api-changes).
+4. **Update your storefront** to handle some small breaking changes in the Shop GraphQL API. See the [storefront migration section](/guides/developer-guide/migrating-from-v1/storefront-migration) for details.

+ 28 - 0
docs/docs/guides/developer-guide/migrating-from-v1/storefront-migration.mdx

@@ -0,0 +1,28 @@
+---
+title: "Storefront Migration"
+sidebar_position: 4
+---
+
+There are relatively few breaking changes that will affect the storefront.
+
+- The `setOrderShippingMethod` mutation now takes an array of shipping method IDs rather than just a single one. This is so we can support multiple shipping methods per Order.
+   ```diff
+   -mutation setOrderShippingMethod($shippingMethodId: ID!) {
+   +mutation setOrderShippingMethod($shippingMethodId: [ID!]!) {
+     setOrderShippingMethod(shippingMethodId: $shippingMethodId) {
+       # ... etc
+     }
+   } 
+   ```
+- The `OrderLine.fulfillments` field has been changed to `OrderLine.fulfillmentLines`. Your storefront may be using this when displaying the details of an Order.
+- If you are using the `graphql-code-generator` package to generate types for your storefront, all monetary values such as `Order.totalWithTax` or `ProductVariant.priceWithTax` are now represented by the new `Money` scalar rather than by an `Int`. You'll need to tell your codegen about this scalar and configure it to be interpreted as a number type:
+   ```diff
+   documents:
+     - "app/**/*.{ts,tsx}"
+     - "!app/generated/*"
+   +config:
+   +  scalars:
+   +    Money: number
+   generates:
+     # ... etc
+   ```

+ 0 - 7
docs/docs/guides/developer-guide/settings-store/index.mdx

@@ -3,13 +3,6 @@ title: 'Settings Store'
 showtoc: true
 ---
 
-
-
-import Tabs from '@theme/Tabs';
-import TabItem from '@theme/TabItem';
-
-# Settings Store
-
 The Settings Store is a flexible system for storing configuration data with support for scoping, permissions,
 and validation. It allows plugins and the core system to store and retrieve arbitrary JSON data with
 fine-grained control over access and isolation.

+ 117 - 0
docs/docs/guides/developer-guide/stand-alone-scripts/index.mdx

@@ -0,0 +1,117 @@
+---
+title: "Stand-alone CLI Scripts"
+---
+
+It is possible to create stand-alone scripts that can be run from the command-line by using the [bootstrapWorker function](/reference/typescript-api/worker/bootstrap-worker/). This can be useful for a variety of use-cases such as running cron jobs or importing data.
+
+## Minimal example
+
+Here's a minimal example of a script which will bootstrap the Vendure Worker and then log the number of products in the database:
+
+```ts title="src/get-product-count.ts"
+import { bootstrapWorker, Logger, ProductService, RequestContextService } from '@vendure/core';
+
+import { config } from './vendure-config';
+
+if (require.main === module) {
+    getProductCount()
+        .then(() => process.exit(0))
+        .catch(err => {
+            Logger.error(err);
+            process.exit(1);
+        });
+}
+
+async function getProductCount() {
+    // This will bootstrap an instance of the Vendure Worker, providing
+    // us access to all of the services defined in the Vendure core.
+    // (but without the unnecessary overhead of the API layer).
+    const { app } = await bootstrapWorker(config);
+
+    // Using `app.get()` we can grab an instance of _any_ provider defined in the
+    // Vendure core as well as by our plugins.
+    const productService = app.get(ProductService);
+
+    // For most service methods, we'll need to pass a RequestContext object.
+    // We can use the RequestContextService to create one.
+    const ctx = await app.get(RequestContextService).create({
+        apiType: 'admin',
+    });
+
+    // We use the `findAll()` method to get the total count. Since we aren't
+    // interested in the actual product objects, we can set the `take` option to 0.
+    const { totalItems } = await productService.findAll(ctx, {take: 0});
+
+    Logger.info(
+        [
+            '\n-----------------------------',
+            `There are ${totalItems} products`,
+            '------------------------------',
+        ].join('\n'),
+    )
+}
+``` 
+
+This script can then be run from the command-line:
+
+```shell
+npx ts-node src/get-product-count.ts
+
+# or
+
+yarn ts-node src/get-product-count.ts
+```
+
+resulting in the following output:
+
+```shell
+info 01/08/23, 11:50 - [Vendure Worker] Bootstrapping Vendure Worker (pid: 4428)...
+info 01/08/23, 11:50 - [Vendure Worker] Vendure Worker is ready
+info 01/08/23, 11:50 - [Vendure Worker]
+-----------------------------------------
+There are 56 products in the database
+-----------------------------------------
+```
+
+## The `app` object
+
+The `app` object returned by the `bootstrapWorker()` function is an instance of the [NestJS Application Context](https://docs.nestjs.com/standalone-applications). It has full access to the NestJS dependency injection container, which means that you can use the `app.get()` method to retrieve any of the services defined in the Vendure core or by any plugins.
+
+```ts title="src/import-customer-data.ts"
+import { bootstrapWorker, CustomerService } from '@vendure/core';
+import { config } from './vendure-config';
+
+// ...
+
+async function importCustomerData() {
+    const { app } = await bootstrapWorker(config);
+    
+    // highlight-start
+    const customerService = app.get(CustomerService);
+    // highlight-end
+}
+```
+
+## Creating a RequestContext
+
+Almost all the methods exposed by Vendure's core services take a `RequestContext` object as the first argument. Usually, this object is created in the [API Layer](/guides/developer-guide/the-api-layer/#resolvers) by the `@Ctx()` decorator, and contains information related to the current API request.
+
+When running a stand-alone script, we aren't making any API requests, so we need to create a `RequestContext` object manually. This can be done using the [`RequestContextService`](/reference/typescript-api/request/request-context-service/):
+
+```ts title="src/get-product-count.ts"
+// ...
+import { RequestContextService } from '@vendure/core';
+
+async function getProductCount() {
+    const { app } = await bootstrapWorker(config);
+    const productService = app.get(ProductService);
+    
+    // highlight-start
+    const ctx = await app.get(RequestContextService).create({
+        apiType: 'admin',
+    });
+    // highlight-end
+    
+    const { totalItems } = await productService.findAll(ctx, {take: 0});
+}
+```

+ 252 - 0
docs/docs/guides/developer-guide/testing/index.mdx

@@ -0,0 +1,252 @@
+---
+title: "Testing"
+showtoc: true
+---
+
+Vendure plugins allow you to extend all aspects of the standard Vendure server. When a plugin gets somewhat complex (defining new entities, extending the GraphQL schema, implementing custom resolvers), you may wish to create automated tests to ensure your plugin is correct.
+
+The `@vendure/testing` package gives you some simple but powerful tooling for creating end-to-end tests for your custom Vendure code.
+
+By "end-to-end" we mean we are testing the _entire server stack_ - from API, to services, to database - by making a real API request, and then making assertions about the response. This is a very effective way to ensure that _all_ parts of your plugin are working correctly together.
+
+:::info
+For a working example of a Vendure plugin with e2e testing, see the [real-world-vendure Reviews plugin](https://github.com/vendurehq/real-world-vendure/tree/master/src/plugins/reviews)
+:::
+
+## Usage
+
+### Install dependencies
+
+* [`@vendure/testing`](https://www.npmjs.com/package/@vendure/testing)
+* [`vitest`](https://vitest.dev/) You'll need to install a testing framework. In this example, we will use [Vitest](https://vitest.dev/) as it has very good support for the modern JavaScript features that Vendure uses, and is very fast.
+* [`graphql-tag`](https://www.npmjs.com/package/graphql-tag) This is not strictly required but makes it much easier to create the DocumentNodes needed to query your server.
+* We also need to install some packages to allow us to compile TypeScript code that uses decorators:
+  - `@swc/core`
+  - `unplugin-swc`
+
+```sh
+npm install --save-dev @vendure/testing vitest graphql-tag @swc/core unplugin-swc
+```
+
+### Configure Vitest
+
+Create a `vitest.config.mts` file in the root of your project:
+
+```ts title="vitest.config.mts"
+import path from 'path';
+import swc from 'unplugin-swc';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+    test: {
+        include: ['**/*.e2e-spec.ts'],
+        typecheck: {
+            tsconfig: path.join(__dirname, 'tsconfig.e2e.json'),
+        },
+    },
+    plugins: [
+        // SWC required to support decorators used in test plugins
+        // See https://github.com/vitest-dev/vitest/issues/708#issuecomment-1118628479
+        // Vite plugin
+        swc.vite({
+            jsc: {
+                transform: {
+                    // See https://github.com/vendurehq/vendure/issues/2099
+                    useDefineForClassFields: false,
+                },
+            },
+        }),
+    ],
+});
+```
+
+and a `tsconfig.e2e.json` tsconfig file for the tests:
+
+```json title="tsconfig.e2e.json"
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "types": ["node"],
+    "lib": ["es2015"],
+    "useDefineForClassFields": false,
+    "skipLibCheck": true,
+    "inlineSourceMap": false,
+    "sourceMap": true,
+    "allowSyntheticDefaultImports": true,
+    "experimentalDecorators": true,
+    "emitDecoratorMetadata": true,
+    "esModuleInterop": true
+  }
+}
+
+```
+
+### Register database-specific initializers
+
+The `@vendure/testing` package uses "initializers" to create the test databases and populate them with initial data. We ship with initializers for `sqljs`, `postgres` and `mysql`. Custom initializers can be created to support running e2e tests against other databases supported by TypeORM. See the [`TestDbInitializer` docs](/reference/typescript-api/testing/test-db-initializer/) for more details.
+
+```ts title="src/plugins/my-plugin/e2e/my-plugin.e2e-spec.ts"
+import {
+    MysqlInitializer,
+    PostgresInitializer,
+    SqljsInitializer,
+    registerInitializer,
+} from '@vendure/testing';
+
+const sqliteDataDir = path.join(__dirname, '__data__');
+
+registerInitializer('sqljs', new SqljsInitializer(sqliteDataDir));
+registerInitializer('postgres', new PostgresInitializer());
+registerInitializer('mysql', new MysqlInitializer());
+```
+
+:::info
+Note re. the `sqliteDataDir`: The first time this test suite is run with the `SqljsInitializer`, the populated data will be saved into an SQLite file, stored in the directory specified by this constructor arg. On subsequent runs of the test suite, the data-population step will be skipped and the initial data directly loaded from the SQLite file. This method of caching significantly speeds up the e2e test runs. All the .sqlite files created in the `sqliteDataDir` can safely be deleted at any time.
+:::
+
+### Create a test environment
+
+The `@vendure/testing` package exports a [`createTestEnvironment` function](/reference/typescript-api/testing/create-test-environment/) which is used to set up a Vendure server and GraphQL clients to interact with both the Shop and Admin APIs:
+
+```ts title="src/plugins/my-plugin/e2e/my-plugin.e2e-spec.ts"
+import { createTestEnvironment, testConfig } from '@vendure/testing';
+import { describe } from 'vitest';
+import { MyPlugin } from '../my-plugin.ts';
+
+describe('my plugin', () => {
+
+    const {server, adminClient, shopClient} = createTestEnvironment({
+        ...testConfig,
+        plugins: [MyPlugin],
+    });
+
+});
+```
+
+Notice that we pass a [`VendureConfig`](/reference/typescript-api/configuration/vendure-config/) object into the `createTestEnvironment` function. The testing package provides a special [`testConfig`](/reference/typescript-api/testing/test-config/) which is pre-configured for e2e tests, but any aspect can be overridden for your tests. Here we are configuring the server to load the plugin under test, `MyPlugin`. 
+
+:::caution
+**Note**: If you need to deeply merge in some custom configuration, use the [`mergeConfig` function](/reference/typescript-api/configuration/merge-config/) which is provided by `@vendure/core`.
+:::
+
+### Initialize the server
+
+The [`TestServer`](/reference/typescript-api/testing/test-server/) needs to be initialized before it can be used. The `TestServer.init()` method takes an options object which defines how to populate the server:
+
+```ts title="src/plugins/my-plugin/e2e/my-plugin.e2e-spec.ts"
+import { beforeAll, afterAll } from 'vitest';
+import { myInitialData } from './fixtures/my-initial-data.ts';
+
+// ...
+
+beforeAll(async () => {
+    await server.init({
+        productsCsvPath: path.join(__dirname, 'fixtures/e2e-products.csv'),
+        initialData: myInitialData,
+        customerCount: 2,
+    });
+    await adminClient.asSuperAdmin();
+}, 60000);
+
+afterAll(async () => {
+    await server.destroy();
+});
+```
+
+An explanation of the options:
+
+* `productsCsvPath` This is a path to an optional CSV file containing product data. See [Product Import Format](/guides/developer-guide/importing-data/#product-import-format). You can see [an example used in the Vendure e2e tests](https://github.com/vendurehq/vendure/blob/master/packages/core/e2e/fixtures/e2e-products-full.csv) to get an idea of how it works. To start with you can just copy this file directly and use it as-is.
+* `initialData` This is an object which defines how other non-product data (Collections, ShippingMethods, Countries etc.) is populated. See [Initial Data Format](/guides/developer-guide/importing-data/#initial-data). You can [copy this example from the Vendure e2e tests](https://github.com/vendurehq/vendure/blob/master/e2e-common/e2e-initial-data.ts)
+* `customerCount` Specifies the number of fake Customers to create. Defaults to 10 if not specified.
+
+### Write your tests
+
+Now we are all set up to create a test. Let's test one of the GraphQL queries used by our fictional plugin:
+
+```ts title="src/plugins/my-plugin/e2e/my-plugin.e2e-spec.ts"
+import gql from 'graphql-tag';
+import { it, expect, beforeAll, afterAll } from 'vitest';
+import { myInitialData } from './fixtures/my-initial-data.ts';
+
+it('myNewQuery returns the expected result', async () => {
+    adminClient.asSuperAdmin(); // log in as the SuperAdmin user
+
+    const query = gql`
+    query MyNewQuery($id: ID!) {
+      myNewQuery(id: $id) {
+        field1
+        field2
+      }
+    }
+  `;
+    const result = await adminClient.query(query, {id: 123});
+
+    expect(result.myNewQuery).toEqual({ /* ... */})
+});
+```
+
+Running the test will then assert that your new query works as expected.
+
+### Run your tests
+
+All that's left is to run your tests to find out whether your code behaves as expected!
+
+:::caution
+**Note:** When using **Vitest** with multiple test suites (multiple `.e2e-spec.ts` files), it will attempt to run them in parallel. If all the test servers are running
+on the same port (the default in the `testConfig` is `3050`), then this will cause a port conflict. To avoid this, you can manually set a unique port for each test suite. Be aware that `mergeConfig` is used here:
+
+```ts title="src/plugins/my-plugin/e2e/my-plugin.e2e-spec.ts"
+import { createTestEnvironment, testConfig } from '@vendure/testing';
+import { mergeConfig } from "@vendure/core";
+import { describe } from 'vitest';
+import { MyPlugin } from '../my-plugin.ts';
+
+describe('my plugin', () => {
+
+    const {server, adminClient, shopClient} = createTestEnvironment(mergeConfig(testConfig, {
+        // highlight-start
+        apiOptions: {
+            port: 3051,
+        },
+        // highlight-end
+        plugins: [MyPlugin],
+    }));
+
+});
+```
+:::
+
+## Accessing internal services
+
+It is possible to access any internal service of the Vendure server via the `server.app` object, which is an instance of the NestJS `INestApplication`.
+
+For example, to access the `ProductService`:
+
+```ts title="src/plugins/my-plugin/e2e/my-plugin.e2e-spec.ts"
+import { createTestEnvironment, testConfig } from '@vendure/testing';
+import { describe, beforeAll } from 'vitest';
+import { MyPlugin } from '../my-plugin.ts';
+
+describe('my plugin', () => {
+    
+    const { server, adminClient, shopClient } = createTestEnvironment({
+        ...testConfig,
+        plugins: [MyPlugin],
+    });
+    
+    // highlight-next-line
+    let productService: ProductService;
+    
+    beforeAll(async () => {
+        await server.init({
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products.csv'),
+            initialData: myInitialData,
+            customerCount: 2,
+        });
+        await adminClient.asSuperAdmin();
+        // highlight-next-line
+        productService = server.app.get(ProductService);
+    }, 60000);
+
+});
+```

+ 77 - 0
docs/docs/guides/developer-guide/updating/index.mdx

@@ -0,0 +1,77 @@
+---
+title: "Updating Vendure"
+showtoc: true
+---
+
+This guide provides guidance for updating the Vendure core framework to a newer version.
+
+## How to update
+
+First, check the [changelog](https://github.com/vendurehq/vendure/blob/master/CHANGELOG.md) for an overview of the changes and any breaking changes in the next version.
+
+In your project's `package.json` file, find all the `@vendure/...` packages and change the version
+to the latest. All the Vendure packages have the same version, and are all released together.
+
+```diff
+{
+  // ...
+  "dependencies": {
+-    "@vendure/common": "1.1.5",
++    "@vendure/common": "1.2.0",
+-    "@vendure/core": "1.1.5",
++    "@vendure/core": "1.2.0",
+     // etc.
+  }
+}
+```
+
+Then run `npm install` or `yarn install` depending on which package manager you prefer.
+
+## Versioning Policy & Breaking changes
+
+Vendure generally follows the [SemVer convention](https://semver.org/) for version numbering. This means that breaking API changes will only be introduced with changes to the major version (the first of the 3 digits in the version).
+
+However, there are some exceptions to this rule:
+
+- In minor versions, (e.g. v2.0 to v2.1) we may update underlying dependencies to new major versions, which may in turn introduce breaking changes. These will be clearly noted in the changelog.
+- In minor versions we may also occasionally introduce non-destructive changes to the database schema. For instance, we may add a new column which would then require a database migration. We will _not_ introduce database schema changes that could potentially result in data loss in a minor version.
+
+Any instances of these exceptions will be clearly indicated in the [Changelog](https://github.com/vendurehq/vendure/blob/master/CHANGELOG.md). The reasoning for these exceptions is discussed in the [Versioning policy RFC](https://github.com/vendurehq/vendure/issues/1846).
+
+### What kinds of breaking changes can be expected?
+
+Major version upgrades (e.g. v1.x to v2.x) can include:
+
+* Changes to the database schema
+* Changes to the GraphQL schema
+* Updates of major underlying libraries, such as upgrading NestJS to a new major version
+
+Every release will be accompanied by an entry in the [changelog](https://github.com/vendurehq/vendure/blob/master/CHANGELOG.md), listing the changes in that release. And breaking changes are clearly listed under a **BREAKING CHANGE** heading.
+
+### Database migrations
+
+Database changes are one of the most common causes for breaking changes. In most cases, the changes are minor (such as the addition of a new column) and non-destructive (i.e. performing the migration has no risk of data loss).
+
+However, some more fundamental changes occasionally require a careful approach to database migration in order to preserve existing data.
+
+The key rule is **never run your production instance with the `synchronize` option set to `true`**. Doing so can cause inadvertent data loss in rare cases.
+
+For any database schema changes, it is advised to:
+
+1. Read the changelog breaking changes entries to see what changes to expect
+2. **Important:** Make a backup of your database!
+3. Create a new database migration as described in the [Migrations guide](/guides/developer-guide/migrations/)
+4. Manually check the migration script. In some cases manual action is needed to customize the script in order to correctly migrate your existing data.
+5. Test the migration script against non-production data.
+6. Only when you have verified that the migration works as expected, run it against your production database.
+
+### GraphQL schema changes
+
+If you are using a code-generation tool (such as [graphql-code-generator](https://graphql-code-generator.com/)) for your custom plugins or storefront, it is a good idea to re-generate after upgrading, which will catch any errors caused by changes to the GraphQL schema.
+
+### TypeScript API changes
+
+If you are using Vendure providers (services, JobQueue, EventBus etc.) in your custom plugins, you should look out for breakages caused by changes to those services. Major changes will be listed in the changelog, but occasionally internal changes may also impact your code. 
+
+The best way to check whether this is the case is to build your entire project after upgrading, to see if any new TypeScript compiler errors emerge.
+

+ 218 - 0
docs/docs/guides/developer-guide/uploading-files/index.mdx

@@ -0,0 +1,218 @@
+---
+title: "Uploading Files"
+showtoc: true
+---
+
+Vendure handles file uploads with the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec). Internally, we use the [graphql-upload package](https://github.com/jaydenseric/graphql-upload). Once uploaded, a file is known as an [Asset](/guides/core-concepts/images-assets/). Assets are typically used for images, but can represent any kind of binary data such as PDF files or videos.
+
+## Upload clients
+
+Here is a [list of client implementations](https://github.com/jaydenseric/graphql-multipart-request-spec#client) that will allow you to upload files using the spec. If you are using Apollo Client, then you should install the [apollo-upload-client](https://github.com/jaydenseric/apollo-upload-client) npm package.
+
+For testing, it is even possible to use a [plain curl request](https://github.com/jaydenseric/graphql-multipart-request-spec#single-file).
+
+## The `createAssets` mutation
+
+The [createAssets mutation](/reference/graphql-api/admin/mutations/#createassets) in the Admin API is the only means of uploading files by default. 
+
+Here's an example of how a file upload would look using the `apollo-upload-client` package:
+
+```tsx
+import { gql, useMutation } from "@apollo/client";
+
+const MUTATION = gql`
+  mutation CreateAssets($input: [CreateAssetInput!]!) {
+    createAssets(input: $input) {
+      ... on Asset {
+        id
+        name
+        fileSize
+      }
+      ... on ErrorResult {
+        message
+      }
+    }
+  }
+`;
+
+function UploadFile() {
+    const [mutate] = useMutation(MUTATION);
+
+    function onChange(event) {
+        const {target} = event;
+        if (target.validity.valid) {
+            mutate({
+                variables: {
+                    input: Array.from(target.files).map((file) => ({file}));
+                }
+            });
+        }
+    }
+
+    return <input type="file" required onChange={onChange}/>;
+}
+```
+
+## Custom upload mutations
+
+How about if you want to implement a custom mutation for file uploads? Let's take an example where we want to allow customers to set an avatar image. To do this, we'll add a [custom field](/guides/developer-guide/custom-fields/) to the Customer entity and then define a new mutation in the Shop API.
+
+### Configuration
+
+Let's define a custom field to associate the avatar `Asset` with the `Customer` entity. To keep everything encapsulated, we'll do all of this in a [plugin](/guides/developer-guide/plugins/)
+
+```ts title="src/plugins/customer-avatar/customer-avatar.plugin.ts"
+import { Asset, LanguageCode, PluginCommonModule, VendurePlugin } from '@vendure/core';
+
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    configure: config => {
+        // highlight-start
+        config.customFields.Customer.push({
+            name: 'avatar',
+            type: 'relation',
+            label: [{languageCode: LanguageCode.en, value: 'Customer avatar'}],
+            entity: Asset,
+            nullable: true,
+        });
+        // highlight-end
+        return config;
+    },
+})
+export class CustomerAvatarPlugin {}
+```
+
+### Schema definition
+
+Next, we will define the schema for the mutation:
+
+```ts title="src/plugins/customer-avatar/api/api-extensions.ts"
+import gql from 'graphql-tag';
+
+export const shopApiExtensions = gql`
+extend type Mutation {
+  setCustomerAvatar(file: Upload!): Asset
+}`
+```
+
+### Resolver
+
+The resolver will make use of the built-in [AssetService](/reference/typescript-api/services/asset-service) to handle the processing of the uploaded file into an Asset.
+
+```ts title="src/plugins/customer-avatar/api/customer-avatar.resolver.ts"
+import { Args, Mutation, Resolver } from '@nestjs/graphql';
+import { Asset } from '@vendure/common/lib/generated-types';
+import {
+    Allow, AssetService, Ctx, CustomerService, isGraphQlErrorResult,
+    Permission, RequestContext, Transaction
+} from '@vendure/core';
+
+@Resolver()
+export class CustomerAvatarResolver {
+    constructor(private assetService: AssetService, private customerService: CustomerService) {}
+
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.Authenticated)
+    async setCustomerAvatar(
+        @Ctx() ctx: RequestContext,
+        @Args() args: { file: any },
+    ): Promise<Asset | undefined> {
+        const userId = ctx.activeUserId;
+        if (!userId) {
+            return;
+        }
+        const customer = await this.customerService.findOneByUserId(ctx, userId);
+        if (!customer) {
+            return;
+        }
+        // Create an Asset from the uploaded file
+        const asset = await this.assetService.create(ctx, {
+            file: args.file,
+            tags: ['avatar'],
+        });
+        // Check to make sure there was no error when
+        // creating the Asset
+        if (isGraphQlErrorResult(asset)) {
+            // MimeTypeError
+            throw asset;
+        }
+        // Asset created correctly, so assign it as the
+        // avatar of the current Customer
+        await this.customerService.update(ctx, {
+            id: customer.id,
+            customFields: {
+                avatarId: asset.id,
+            },
+        });
+
+        return asset;
+    }
+}
+```
+
+### Complete Customer Avatar Plugin
+
+Let's put all these parts together into the plugin:
+
+```ts
+import { Asset, PluginCommonModule, VendurePlugin } from '@vendure/core';
+
+import { shopApiExtensions } from './api/api-extensions';
+import { CustomerAvatarResolver } from './api/customer-avatar.resolver';
+
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    shopApiExtensions: {
+        schema: shopApiExtensions,
+        resolvers: [CustomerAvatarResolver],
+    },
+    configuration: config => {
+        config.customFields.Customer.push({
+            name: 'avatar',
+            type: 'relation',
+            label: [{languageCode: LanguageCode.en, value: 'Customer avatar'}],
+            entity: Asset,
+            nullable: true,
+        });
+        return config;
+    },
+})
+export class CustomerAvatarPlugin {
+}
+```
+
+### Uploading a Customer Avatar
+
+In our storefront, we would then upload a Customer's avatar like this:
+
+```tsx
+import { gql, useMutation } from "@apollo/client";
+
+const MUTATION = gql`
+  mutation SetCustomerAvatar($file: Upload!) {
+    setCustomerAvatar(file: $file) {
+      id
+      name
+      fileSize
+    }
+  }
+`;
+
+function UploadAvatar() {
+  const [mutate] = useMutation(MUTATION);
+
+  function onChange(event) {
+    const { target } = event;  
+    if (target.validity.valid && target.files.length === 1) {
+      mutate({ 
+        variables: {
+          file: target.files[0],
+        }  
+      });
+    }
+  }
+
+  return <input type="file" required onChange={onChange} />;
+}
+```

+ 330 - 0
docs/docs/guides/extending-the-admin-ui/creating-detail-views/index.mdx

@@ -0,0 +1,330 @@
+---
+title: 'Creating Detail Views'
+---
+
+The two most common type of components you'll be creating in your UI extensions are list components and detail components.
+
+In Vendure, we have standardized the way you write these components so that your ui extensions can be made to fit seamlessly into the rest of the app.
+
+:::note
+The specific pattern described here is for Angular-based components. It is also possible to create detail views using React components, but 
+in that case you won't be able to use the built-in Angular-specific components.
+:::
+
+## Example: Creating a Product Detail View
+
+Let's say you have a plugin which adds a new entity to the database called `ProductReview`. You have already created a [list view](/guides/extending-the-admin-ui/creating-list-views/), and
+now you need a detail view which can be used to view and edit individual reviews.
+
+### Extend the TypedBaseDetailComponent class
+
+The detail component itself is an Angular component which extends the [BaseDetailComponent](/reference/admin-ui-api/list-detail-views/base-detail-component/) or [TypedBaseDetailComponent](/reference/admin-ui-api/list-detail-views/typed-base-detail-component) class.
+
+This example assumes you have set up your project to use code generation as described in the [GraphQL code generation guide](/guides/how-to/codegen/#codegen-for-admin-ui-extensions).
+
+```ts title="src/plugins/reviews/ui/components/review-detail/review-detail.component.ts"
+import { ResultOf } from '@graphql-typed-document-node/core';
+import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from '@angular/core';
+import { FormBuilder } from '@angular/forms';
+import { TypedBaseDetailComponent, LanguageCode, NotificationService, SharedModule } from '@vendure/admin-ui/core';
+
+// This is the TypedDocumentNode & type generated by GraphQL Code Generator
+import { graphql } from '../../gql';
+
+export const reviewDetailFragment = graphql(`
+  fragment ReviewDetail on ProductReview {
+    id
+    createdAt
+    updatedAt
+    title
+    rating
+    text
+    authorName
+    productId
+  }
+`);
+
+export const getReviewDetailDocument = graphql(`
+  query GetReviewDetail($id: ID!) {
+    review(id: $id) {
+      ...ReviewDetail
+    }
+  }
+`);
+
+export const createReviewDocument = graphql(`
+  mutation CreateReview($input: CreateProductReviewInput!) {
+    createProductReview(input: $input) {
+      ...ReviewDetail
+    }
+  }
+`);
+
+export const updateReviewDocument = graphql(`
+  mutation UpdateReview($input: UpdateProductReviewInput!) {
+    updateProductReview(input: $input) {
+      ...ReviewDetail
+    }
+  }
+`);
+
+@Component({
+    selector: 'review-detail',
+    templateUrl: './review-detail.component.html',
+    styleUrls: ['./review-detail.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+    standalone: true,
+    imports: [SharedModule],
+})
+export class ReviewDetailComponent extends TypedBaseDetailComponent<typeof getReviewDetailDocument, 'review'> implements OnInit, OnDestroy {
+    detailForm = this.formBuilder.group({
+        title: [''],
+        rating: [1],
+        authorName: [''],
+    });
+
+    constructor(private formBuilder: FormBuilder, private notificationService: NotificationService) {
+        super();
+    }
+
+    ngOnInit() {
+        this.init();
+    }
+
+    ngOnDestroy() {
+        this.destroy();
+    }
+
+    create() {
+        const { title, rating, authorName } = this.detailForm.value;
+        if (!title || rating == null || !authorName) {
+            return;
+        }
+        this.dataService
+            .mutate(createReviewDocument, {
+                input: { title, rating, authorName },
+            })
+            .subscribe(({ createProductReview }) => {
+                if (createProductReview.id) {
+                    this.notificationService.success('Review created');
+                    this.router.navigate(['extensions', 'reviews', createProductReview.id]);
+                }
+            });
+    }
+
+    update() {
+        const { title, rating, authorName } = this.detailForm.value;
+        this.dataService
+            .mutate(updateReviewDocument, {
+                input: { id: this.id, title, rating, authorName },
+            })
+            .subscribe(() => {
+                this.notificationService.success('Review updated');
+            });
+    }
+
+    protected setFormValues(entity: NonNullable<ResultOf<typeof getReviewDetailDocument>['review']>, languageCode: LanguageCode): void {
+        this.detailForm.patchValue({
+            title: entity.name,
+            rating: entity.rating,
+            authorName: entity.authorName,
+            productId: entity.productId,
+        });
+    }
+}
+```
+
+### Create the template
+
+Here is the standard layout for detail views:
+
+```html
+<vdr-page-block>
+    <vdr-action-bar>
+        <vdr-ab-left></vdr-ab-left>
+        <vdr-ab-right>
+            <button
+                class="button primary"
+                *ngIf="isNew$ | async; else updateButton"
+                (click)="create()"
+                [disabled]="detailForm.pristine || detailForm.invalid"
+            >
+                {{ 'common.create' | translate }}
+            </button>
+            <ng-template #updateButton>
+                <button
+                    class="btn btn-primary"
+                    (click)="update()"
+                    [disabled]="detailForm.pristine || detailForm.invalid"
+                >
+                    {{ 'common.update' | translate }}
+                </button>
+            </ng-template>
+        </vdr-ab-right>
+    </vdr-action-bar>
+</vdr-page-block>
+
+<form class="form" [formGroup]="detailForm">
+    <vdr-page-detail-layout>
+        <!-- The sidebar is used for displaying "metadata" type information about the entity -->
+        <vdr-page-detail-sidebar>
+            <vdr-card *ngIf="entity$ | async as entity">
+                <vdr-page-entity-info [entity]="entity" />
+            </vdr-card>
+        </vdr-page-detail-sidebar>
+
+        <!-- The main content area is used for displaying the entity's fields -->
+        <vdr-page-block>
+            <!-- The vdr-card is the container for grouping items together on a page -->
+            <!-- it can also take an optional [title] property to display a title -->
+            <vdr-card>
+                <!-- the form-grid class is used to lay out the form fields -->
+                <div class="form-grid">
+                    <vdr-form-field label="Title" for="title">
+                        <input id="title" type="text" formControlName="title" />
+                    </vdr-form-field>
+                    <vdr-form-field label="Rating" for="rating">
+                        <input id="rating" type="number" min="1" max="5" formControlName="rating" />
+                    </vdr-form-field>
+
+                    <!-- etc -->
+                </div>
+            </vdr-card>
+        </vdr-page-block>
+    </vdr-page-detail-layout>
+</form>
+```
+
+### Route config
+
+Here's how the routing would look for a typical list & detail view:
+
+```ts title="src/plugins/reviews/ui/routes.ts"
+import { registerRouteComponent } from '@vendure/admin-ui/core';
+
+import { ReviewDetailComponent, getReviewDetailDocument } from './components/review-detail/review-detail.component';
+import { ReviewListComponent } from './components/review-list/review-list.component';
+
+export default [
+    // List view
+    registerRouteComponent({
+        path: '',
+        component: ReviewListComponent,
+        breadcrumb: 'Product reviews',
+    }),
+    // highlight-start
+    // Detail view
+    registerRouteComponent({
+        path: ':id',
+        component: ReviewDetailComponent,
+        query: getReviewDetailDocument,
+        entityKey: 'productReview',
+        getBreadcrumbs: entity => [
+            {
+                label: 'Product reviews',
+                link: ['/extensions', 'product-reviews'],
+            },
+            {
+                label: `#${entity?.id} (${entity?.product.name})`,
+                link: [],
+            },
+        ],
+    }),
+    // highlight-end
+]
+```
+
+## Supporting custom fields
+
+From Vendure v2.2, it is possible for your [custom entities to support custom fields](/guides/developer-guide/database-entity/#supporting-custom-fields).
+
+If you have set up your entity to support custom fields, and you want custom fields to be available in the Admin UI detail view,
+you need to add the following to your detail component:
+
+```ts title="src/plugins/reviews/ui/components/review-detail/review-detail.component.ts"
+// highlight-next-line
+import { getCustomFieldsDefaults } from '@vendure/admin-ui/core';
+
+@Component({
+    selector: 'review-detail',
+    templateUrl: './review-detail.component.html',
+    styleUrls: ['./review-detail.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+    standalone: true,
+    imports: [SharedModule],
+})
+export class ReviewDetailComponent extends TypedBaseDetailComponent<typeof getReviewDetailDocument, 'review'> implements OnInit, OnDestroy {
+
+    // highlight-next-line
+    customFields = this.getCustomFieldConfig('ProductReview');
+
+    detailForm = this.formBuilder.group({
+        title: [''],
+        rating: [1],
+        authorName: [''],
+        // highlight-next-line
+        customFields: this.formBuilder.group(getCustomFieldsDefaults(this.customFields)),
+    });
+
+    protected setFormValues(entity: NonNullable<ResultOf<typeof getReviewDetailDocument>['review']>, languageCode: LanguageCode): void {
+        this.detailForm.patchValue({
+            title: entity.name,
+            rating: entity.rating,
+            authorName: entity.authorName,
+            productId: entity.productId,
+        });
+        // highlight-start
+        if (this.customFields.length) {
+            this.setCustomFieldFormValues(this.customFields, this.detailForm.get('customFields'), entity);
+        }
+        // highlight-end
+    }
+}
+```
+
+Then add a card for your custom fields to the template:
+
+```html title="src/plugins/reviews/ui/components/review-detail/review-detail.component.html"
+<form class="form" [formGroup]="detailForm">
+    <vdr-page-detail-layout>
+        <!-- The sidebar is used for displaying "metadata" type information about the entity -->
+        <vdr-page-detail-sidebar>
+            <vdr-card *ngIf="entity$ | async as entity">
+                <vdr-page-entity-info [entity]="entity" />
+            </vdr-card>
+        </vdr-page-detail-sidebar>
+
+        <!-- The main content area is used for displaying the entity's fields -->
+        <vdr-page-block>
+            <!-- The vdr-card is the container for grouping items together on a page -->
+            <!-- it can also take an optional [title] property to display a title -->
+            <vdr-card>
+                <!-- the form-grid class is used to lay out the form fields -->
+                <div class="form-grid">
+                    <vdr-form-field label="Title" for="title">
+                        <input id="title" type="text" formControlName="title" />
+                    </vdr-form-field>
+                    <vdr-form-field label="Rating" for="rating">
+                        <input id="rating" type="number" min="1" max="5" formControlName="rating" />
+                    </vdr-form-field>
+
+                    <!-- etc -->
+                </div>
+            </vdr-card>
+            // highlight-start
+            <vdr-card
+                    formGroupName="customFields"
+                    *ngIf="customFields.length"
+                    [title]="'common.custom-fields' | translate"
+            >
+                <vdr-tabbed-custom-fields
+                        entityName="ProductReview"
+                        [customFields]="customFields"
+                        [customFieldsFormGroup]="detailForm.get('customFields')"
+                ></vdr-tabbed-custom-fields>
+            </vdr-card>
+            // highlight-end
+        </vdr-page-block>
+    </vdr-page-detail-layout>
+</form>
+```

+ 1900 - 0
docs/docs/guides/extending-the-dashboard/migration/index.mdx

@@ -0,0 +1,1900 @@
+---
+title: Migrating from Admin UI
+sidebar_position: 1
+---
+
+If you have existing extensions to the legacy Angular-based Admin UI, you will want to migrate to the new Dashboard to enjoy
+an improved developer experience, many more customization options, and ongoing support from the Vendure team.
+
+:::warning
+The Angular Admin UI will not be maintained after **July 2026**. Until then, we will continue patching critical bugs and security issues. 
+Community contributions will always be merged and released.
+:::
+
+## Running In Parallel
+
+A recommended approach to migrating is to run both the Admin UI _and_ the new Dashboard in parallel. This allows you to start building
+new features right away with the new Dashboard while maintaining access to existing features that have not yet been migrated.
+
+To do so, follow the instructions to [set up the Dashboard](/guides/extending-the-dashboard/getting-started/#installation--setup).
+Both plugins can now be used simultaneously without any special configuration.
+
+## AI-Assisted Migration
+
+We highly recommend using AI tools such as Claude Code, Codex etc to assist with migrations from the legacy Angular-based UI extensions
+to the new React-based Dashboard.
+
+:::info
+The results of AI-assisted migration are heavily dependent on the model that you use. We tested with
+Claude Code using Sonnet 4.5 & Codex using gpt-5-codex
+:::
+
+In our testing, we were able to perform complete migrations quickly using the following approach:
+
+1. Use the provided prompt or Claude skill and specify which plugin you wish to migrate (do 1 at a time)
+2. Allow the AI tool to complete the migration
+3. Manually clean up & fix any issues that remain
+
+Using this approach we were able to migrate complete plugins involving list/details views, widgets, and custom field components
+in around 20-30 minutes.
+
+### Full Prompt
+
+Give a prompt like this to your AI assistant and make sure to specify the plugin by path, i.e.:
+
+```
+Migrate the plugin at @src/plugins/my-plugin/ 
+to use the new dashboard.
+```
+
+Then paste the following prompt in full:
+
+<div style={{ width: '100%', height: '500px', overflow: 'auto', marginBottom: '20px' }}>
+
+<!-- Note: the following code block should not be edited. It is auto-generated from the files in the 
+     `.claude/skills/vendure-dashboard-migration` dir, by running the npm script `generate-migration-prompt` from
+     the `./docs` dir. -->
+````md
+## Instructions
+
+1. If not explicitly stated by the user, find out which plugin they want to migrate.
+2. Read and understand the overall rules for migration
+    - the "General" section below
+    - the "Common Tasks" section below
+3. Check the tsconfig setup <tsconfig-setup>. This may or may not already be set up.
+    - the "TSConfig setup" section below
+4. Identify each part of the Admin UI extensions that needs to be
+   migrated, and use the data from the appropriate sections to guide
+   the migration:
+    - the "Forms" section below
+    - the "Custom Field Inputs" section below
+    - the "List Pages" section below
+    - the "Detail Pages" section below
+    - the "Adding Nav Menu Items" section below
+    - the "Action Bar Items" section below
+    - the "Custom Detail Components" section below
+    - the "Page Tabs" section below
+    - the "Widgets" section below
+5. Ensure you have followed the instructions marked "Important" for each section
+
+## General
+
+- For short we use "old" to refer to code written for the Angular Admin UI, and "new" for the React Dashboard
+- old code is usually in a plugin's "ui" dir
+- new code should be in a plugin's "dashboard" dir
+- new code imports all components from `@vendure/dashboard`. It can also import the following as needed:
+    - hooks or anything else needed from `react`
+    - hooks etc from `@tanstack/react-query`
+    - `Link`, `useNavigate` etc from `@tanstack/react-router`
+    - `useForm` etc from `react-hook-form`
+    - `toast` from `sonner`
+    - icons from `lucide-react`
+    - for i18n: `Trans`, `useLingui` from `@lingui/react/macro`
+- Default to the style conventions of the current project as much as possible (single vs double quotes,
+  indent size etc)
+
+
+## Directory Structure
+Given as an example - projects may differ in conventions
+
+### Old
+
+```
+- /path/to/plugin
+    - /ui
+        - providers.ts
+        - routes.ts
+            - /components
+                - /example
+                    - example.component.ts
+                    - example.component.html
+                    - example.component.scss
+                    - example.graphql.ts
+```
+
+
+### New
+
+```
+- /path/to/plugin
+    - /dashboard
+        - index.tsx
+            - /components
+                - example.tsx
+```
+
+## Registering extensions
+
+### Old
+
+```ts title="src/plugins/my-plugin/my.plugin.ts"
+import * as path from 'path';
+import { VendurePlugin } from '@vendure/core';
+import { AdminUiExtension } from '@vendure/ui-devkit/compiler';
+
+@VendurePlugin({
+    // ...
+})
+export class MyPlugin {
+    static ui: AdminUiExtension = {
+        id: 'my-plugin-ui',
+        extensionPath: path.join(__dirname, 'ui'),
+        routes: [{ route: 'my-plugin', filePath: 'routes.ts' }],
+        providers: ['providers.ts'],
+    };
+}
+```
+
+### New
+
+```ts title="src/plugins/my-plugin/my.plugin.ts"
+import { VendurePlugin } from '@vendure/core';
+
+@VendurePlugin({
+    // ...
+    // Note that this needs to match the relative path to the
+    // index.tsx file from the plugin file
+    dashboard: '../dashboard/index.tsx',
+})
+export class MyPlugin {
+    // Do not remove any existing AdminUiExtension def
+    // to preserve backward compatibility
+    static ui: AdminUiExtension = { /* ... */ }
+}
+```
+
+Important:
+  - Ensure the `dashboard` path is correct relative to the locations of the plugin.ts file and the index.ts file
+
+## Styling
+
+### Old
+
+custom design system based on Clarity UI
+
+```html
+<button class="button primary">Primary</button>
+<button class="button secondary">Secondary</button>
+<button class="button success">Success</button>
+<button class="button warning">Warning</button>
+<button class="button danger">Danger</button>
+
+<button class="button-ghost">Ghost</button>
+
+<a class="button-ghost" [routerLink]="['/extensions/my-plugin/my-custom-route']">
+    <clr-icon shape="arrow" dir="right"></clr-icon>
+    John Smith
+</a>
+
+<button class="button-small">Small</button>
+
+<button class="button-small">
+    <clr-icon shape="layers"></clr-icon>
+    Assign to channel
+</button>
+
+<clr-icon shape="star" size="8"></clr-icon>
+
+<img [src]="product.featuredAsset?.preview + '?preset=small'" alt="Product preview" />
+```
+
+### New
+
+tailwind + shadcn/ui. Shadcn components import from `@vendure/dashboard`
+
+```tsx
+import { Button, DetailPageButton, VendureImage } from '@vendure/dashboard';
+import { Star } from 'lucide-react';
+
+export function MyComponent() {
+    // non-exhaustive - all standard Shadcn props are available
+    return (
+        <Button variant="default">Primary</Button>
+        <Button variant="secondary">Secondary</Button>
+        <Button variant="outline">Outline</Button>
+        <Button variant="destructive">Danger</Button>
+        <Button variant="ghost">Ghost</Button>
+        
+        <DetailPageButton id="123" label="John Smith" />
+        <DetailPageButton href="/affiliates/my-custom-route" label="John Smith" />
+        
+        <Star />
+        
+        <VendureImage
+            src={entity.product.featuredAsset}
+            alt={entity.product.name}
+            preset='small'
+        />
+    )
+} 
+```
+
+Important:
+
+  - When using `Badge`, prefer variant="secondary" unless especially important data
+  - Where possible avoid specific tailwind colours like `text-blue-600`. Instead use (where possible)
+    the Shadcn theme colours, eg:
+    ```
+    --color-background
+    --color-foreground
+    --color-primary
+    --color-primary-foreground
+    --color-secondary
+    --color-secondary-foreground
+    --color-muted
+    --color-muted-foreground
+    --color-accent
+    --color-accent-foreground
+    --color-destructive
+    --color-destructive-foreground
+    --color-success
+    --color-success-foreground
+    ```
+  - Buttons which link to detail pages should use `DetailPageButton`
+
+## Data access
+
+### Old
+
+```ts
+import { DataService } from '@vendure/admin-ui/core';
+import { graphql } from "../gql";  
+  
+export const GET_CUSTOMER_NAME = graphql(`  
+    query GetCustomerName($id: ID!) {  
+        customer(id: $id) {  
+            id  
+            firstName            
+            lastName
+            addresses {
+              ...AddressFragment
+            }
+        }    
+	}
+`);
+
+this.dataService.query(GET_CUSTOMER_NAME, {  
+    id: customerId,  
+}),
+```
+
+### New
+
+```ts
+import { useQuery } from '@tanstack/react-query';  
+import { api } from '@vendure/dashboard';  
+import { graphql } from '@/gql';
+
+const addressFragment = graphql(`
+   # ...
+`);
+
+const getCustomerNameDocument = graphql(`  
+    query GetCustomerName($id: ID!) {  
+        customer(id: $id) {  
+            id  
+            firstName            
+            lastName              
+            addresses {
+              ...AddressFragment
+            }
+        }    
+	}
+`, [addressFragment]);  // Fragments MUST be explicitly referenced
+
+const { data, isLoading, error } = useQuery({  
+	queryKey: ['customer-name', customerId],  
+	queryFn: () => api.query(getCustomerNameDocument, { id: customerId }),
+});
+```
+
+Note on graphql fragments: if common fragments are used across files, you may need
+to extract them into a common-fragments.graphql.ts file, because with gql.tada they
+*must* be explicitly referenced in every document that uses them.
+
+## Common Tasks
+
+### Formatting Dates, Currencies, and Numbers
+
+```tsx
+import {useLocalFormat} from '@vendure/dashboard';
+// ...
+// Intl API formatting tools
+const {
+    formatCurrency,
+    formatNumber,
+    formatDate,
+    formatRelativeDate,
+    formatLanguageName,
+    formatRegionName,
+    formatCurrencyName,
+    toMajorUnits,
+    toMinorUnits,
+} = useLocalFormat();
+
+formatCurrency(value: number, currency: string, precision?: number)
+formatCurrencyName(currencyCode: string, display: 'full' | 'symbol' | 'name' = 'full')
+formatNumber(value: number) // human-readable
+formatDate(value: string | Date, options?: Intl.DateTimeFormatOptions)
+formatRelativeDate(value: string | Date, options?: Intl.RelativeTimeFormatOptions)
+```
+
+### Links
+
+Example link destinations:
+- Customer detail | <Link to="/customers/$id" params={{ id }}>text</Link>
+- Customer list | <Link to="/customers">text</Link>
+- Order detail | <Link to="/orders/$id" params={{ id }}>text</Link>
+
+Important: when linking to detail pages, prefer the `DetailPageButton`. If not in a table column,
+add `className='border'`.
+
+## TSConfig setup
+
+If not already set up, we need to make sure we have configured tsconfig with:
+
+1. jsx support. Usually create `tsconfig.dashboard.json` like this:
+    ```json
+    {
+      "extends": "./tsconfig.json",
+      "compilerOptions": {
+        "composite": true,
+        "jsx": "react-jsx"
+      },
+      "include": [
+        "src/dashboard/**/*.ts",
+        "src/dashboard/**/*.tsx"
+      ]
+    }
+    ```
+   then reference it from the appropriate tsconfig.json
+    ```
+    {
+        // ...etc
+        "references": [
+            {
+                "path": "./tsconfig.dashboard.json"
+            },
+        ]
+    }
+    ```
+   This may already be set up (check this). In an Nx-like monorepo
+   where each plugin is a separate project, this will need to be done
+   per-plugin.
+2. Path mapping.
+    ```json
+     "paths": {
+        // Import alias for the GraphQL types, this needs to point to
+        // the location specified in the vite.config.mts file as `gqlOutputPath`
+        // so will vary depending on project structure
+        "@/gql": ["./apps/server/src/gql/graphql.ts"],
+        // This line allows TypeScript to properly resolve internal
+        // Vendure Dashboard imports, which is necessary for
+        // type safety in your dashboard extensions.
+        // This path assumes a root-level tsconfig.json file.
+        // You may need to adjust it if your project structure is different.
+        "@/vdb/*": [
+          "./node_modules/@vendure/dashboard/src/lib/*"
+     }
+     ```
+   In an Nx-like monorepo, this would be added to the tsconfig.base.json or
+   equivalent.
+
+## Forms
+
+### Old (Angular)
+```html
+<div class="form-grid">
+    <vdr-form-field label="Page title">
+        <input type="text" />
+    </vdr-form-field>
+    <vdr-form-field label="Select input">
+        <select>
+            <option>Option 1</option>
+            <option>Option 2</option>
+        </select>
+    </vdr-form-field>
+    <vdr-form-field label="Checkbox input">
+        <input type="checkbox" />
+    </vdr-form-field>
+    <vdr-form-field label="Textarea input">
+        <textarea></textarea>
+    </vdr-form-field>
+    <vdr-form-field label="Invalid with error">
+        <input type="text" [formControl]="invalidFormControl" />
+    </vdr-form-field>
+    <vdr-rich-text-editor
+        class="form-grid-span"
+        label="Description"
+    ></vdr-rich-text-editor>
+</div>
+```
+
+### New (React Dashboard)
+```tsx
+<PageBlock column="main" blockId="main-form">
+    <DetailFormGrid>
+        <FormFieldWrapper
+            control={form.control}
+            name="title"
+            label="Title"
+            render={({ field }) => <Input {...field} />}
+        />
+        <FormFieldWrapper
+            control={form.control}
+            name="slug"
+            label="Slug"
+            render={({ field }) => <Input {...field} />}
+        />
+    </DetailFormGrid>
+    <div className="space-y-6">
+        <FormFieldWrapper
+            control={form.control}
+            name="body"
+            label="Content"
+            render={({ field }) => (
+                <RichTextInput value={field.value ?? ''} onChange={field.onChange} />
+            )}
+        />
+    </div>
+</PageBlock>;
+```
+
+## Custom Field Inputs
+
+### Old (Angular)
+
+```ts title="src/plugins/common/ui/components/slider-form-input/slider-form-input.component.ts"
+import { Component } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { IntCustomFieldConfig, SharedModule, FormInputComponent } from '@vendure/admin-ui/core';
+
+@Component({
+    template: `
+        <input
+            type="range"
+            [min]="config.min || 0"
+            [max]="config.max || 100"
+            [formControl]="formControl" />
+        {{ formControl.value }}
+    `,
+    standalone: true,
+    imports: [SharedModule],
+})
+export class SliderControlComponent implements FormInputComponent<IntCustomFieldConfig> {
+    readonly: boolean;
+    config: IntCustomFieldConfig;
+    formControl: FormControl;
+}
+```
+
+```ts title="src/plugins/common/ui/providers.ts"
+import { registerFormInputComponent } from '@vendure/admin-ui/core';
+import { SliderControlComponent } from './components/slider-form-input/slider-form-input.component';
+
+export default [
+    registerFormInputComponent('slider-form-input', SliderControlComponent),
+];
+```
+
+### New (React Dashboard)
+
+```tsx title="src/plugins/my-plugin/dashboard/components/color-picker.tsx"
+import { Button, Card, CardContent, cn, DashboardFormComponent, Input } from '@vendure/dashboard';
+import { useState } from 'react';
+import { useFormContext } from 'react-hook-form';
+
+// By typing your component as DashboardFormComponent, the props will be correctly typed
+export const ColorPickerComponent: DashboardFormComponent = ({ value, onChange, name }) => {
+    const [isOpen, setIsOpen] = useState(false);
+
+    const { getFieldState } = useFormContext();
+    const error = getFieldState(name).error;
+    const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FECA57', '#FF9FF3', '#54A0FF', '#5F27CD'];
+
+    return (
+        <div className="space-y-2">
+            <div className="flex items-center space-x-2">
+                <Button
+                    type="button"
+                    variant="outline"
+                    size="icon"
+                    className={cn('w-8 h-8 border-2 border-gray-300 p-0', error && 'border-red-500')}
+                    style={{ backgroundColor: error ? 'transparent' : value || '#ffffff' }}
+                    onClick={() => setIsOpen(!isOpen)}
+                />
+                <Input value={value || ''} onChange={e => onChange(e.target.value)} placeholder="#ffffff" />
+            </div>
+
+            {isOpen && (
+                <Card>
+                    <CardContent className="grid grid-cols-4 gap-2 p-2">
+                        {colors.map(color => (
+                            <Button
+                                key={color}
+                                type="button"
+                                variant="outline"
+                                size="icon"
+                                className="w-8 h-8 border-2 border-gray-300 hover:border-gray-500 p-0"
+                                style={{ backgroundColor: color }}
+                                onClick={() => {
+                                    onChange(color);
+                                    setIsOpen(false);
+                                }}
+                            />
+                        ))}
+                    </CardContent>
+                </Card>
+            )}
+        </div>
+    );
+};
+```
+
+```tsx title="src/plugins/my-plugin/dashboard/index.tsx"
+import { defineDashboardExtension } from '@vendure/dashboard';
+import { ColorPickerComponent } from './components/color-picker';
+
+defineDashboardExtension({
+    customFormComponents: {
+        // Custom field components for custom fields
+        customFields: [
+            {
+                // The "id" is a global identifier for this custom component. We will
+                // reference it in the next step.
+                id: 'color-picker',
+                component: ColorPickerComponent,
+            },
+        ],
+    },
+    // ... other extension properties
+});
+```
+
+## List Pages
+
+### Old (Angular)
+```ts
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { TypedBaseListComponent, SharedModule } from '@vendure/admin-ui/core';
+// This is the TypedDocumentNode generated by GraphQL Code Generator
+import { graphql } from '../../gql';
+
+const getReviewListDocument = graphql(`
+  query GetReviewList($options: ReviewListOptions) {
+    reviews(options: $options) {
+      items {
+        id
+        createdAt
+        updatedAt
+        title
+        rating
+        text
+        authorName
+        productId
+      }
+      totalItems
+    }
+  }
+`);
+
+@Component({
+selector: 'review-list',
+templateUrl: './review-list.component.html',
+styleUrls: ['./review-list.component.scss'],
+changeDetection: ChangeDetectionStrategy.OnPush,
+standalone: true,
+imports: [SharedModule],
+})
+export class ReviewListComponent extends TypedBaseListComponent<typeof getReviewListDocument, 'reviews'> {
+
+    // Here we set up the filters that will be available
+    // to use in the data table
+    readonly filters = this.createFilterCollection()
+        .addIdFilter()
+        .addDateFilters()
+        .addFilter({
+            name: 'title',
+            type: {kind: 'text'},
+            label: 'Title',
+            filterField: 'title',
+        })
+        .addFilter({
+            name: 'rating',
+            type: {kind: 'number'},
+            label: 'Rating',
+            filterField: 'rating',
+        })
+        .addFilter({
+            name: 'authorName',
+            type: {kind: 'text'},
+            label: 'Author',
+            filterField: 'authorName',
+        })
+        .connectToRoute(this.route);
+
+    // Here we set up the sorting options that will be available
+    // to use in the data table
+    readonly sorts = this.createSortCollection()
+        .defaultSort('createdAt', 'DESC')
+        .addSort({name: 'createdAt'})
+        .addSort({name: 'updatedAt'})
+        .addSort({name: 'title'})
+        .addSort({name: 'rating'})
+        .addSort({name: 'authorName'})
+        .connectToRoute(this.route);
+
+    constructor() {
+        super();
+        super.configure({
+            document: getReviewListDocument,
+            getItems: data => data.reviews,
+            setVariables: (skip, take) => ({
+                options: {
+                    skip,
+                    take,
+                    filter: {
+                        title: {
+                            contains: this.searchTermControl.value,
+                        },
+                        ...this.filters.createFilterInput(),
+                    },
+                    sort: this.sorts.createSortInput(),
+                },
+            }),
+            refreshListOnChanges: [this.filters.valueChanges, this.sorts.valueChanges],
+        });
+    }
+}
+```
+
+```html
+<!-- optional if you want some buttons at the top -->
+<vdr-page-block>
+    <vdr-action-bar>
+        <vdr-ab-left></vdr-ab-left>
+        <vdr-ab-right>
+            <a class="btn btn-primary" *vdrIfPermissions="['CreateReview']" [routerLink]="['./', 'create']">
+                <clr-icon shape="plus"></clr-icon>
+                Create a review
+            </a>
+        </vdr-ab-right>
+    </vdr-action-bar>
+</vdr-page-block>
+
+<!-- The data table -->
+<vdr-data-table-2
+        id="review-list"
+        [items]="items$ | async"
+        [itemsPerPage]="itemsPerPage$ | async"
+        [totalItems]="totalItems$ | async"
+        [currentPage]="currentPage$ | async"
+        [filters]="filters"
+        (pageChange)="setPageNumber($event)"
+        (itemsPerPageChange)="setItemsPerPage($event)"
+>
+    <!-- optional if you want to support bulk actions -->
+    <vdr-bulk-action-menu
+            locationId="review-list"
+            [hostComponent]="this"
+            [selectionManager]="selectionManager"
+    />
+    
+    <!-- Adds a search bar -->
+    <vdr-dt2-search
+            [searchTermControl]="searchTermControl"
+            searchTermPlaceholder="Filter by title"
+    />
+    
+    <!-- Here we define all the available columns -->
+    <vdr-dt2-column id="id" [heading]="'common.id' | translate" [hiddenByDefault]="true">
+        <ng-template let-review="item">
+            {{ review.id }}
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column
+            id="created-at"
+            [heading]="'common.created-at' | translate"
+            [hiddenByDefault]="true"
+            [sort]="sorts.get('createdAt')"
+    >
+        <ng-template let-review="item">
+            {{ review.createdAt | localeDate : 'short' }}
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column
+            id="updated-at"
+            [heading]="'common.updated-at' | translate"
+            [hiddenByDefault]="true"
+            [sort]="sorts.get('updatedAt')"
+    >
+        <ng-template let-review="item">
+            {{ review.updatedAt | localeDate : 'short' }}
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column id="title" heading="Title" [optional]="false" [sort]="sorts.get('title')">
+        <ng-template let-review="item">
+            <a class="button-ghost" [routerLink]="['./', review.id]"
+            ><span>{{ review.title }}</span>
+                <clr-icon shape="arrow right"></clr-icon>
+            </a>
+        </ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column id="rating" heading="Rating" [sort]="sorts.get('rating')">
+        <ng-template let-review="item"><my-star-rating-component [rating]="review.rating"    /></ng-template>
+    </vdr-dt2-column>
+    <vdr-dt2-column id="author" heading="Author" [sort]="sorts.get('authorName')">
+        <ng-template let-review="item">{{ review.authorName }}</ng-template>
+    </vdr-dt2-column>
+</vdr-data-table-2>
+```
+
+```ts
+import { registerRouteComponent } from '@vendure/admin-ui/core';
+
+import { ReviewListComponent } from './components/review-list/review-list.component';
+
+export default [
+    registerRouteComponent({
+        path: '',
+        component: ReviewListComponent,
+        breadcrumb: 'Product reviews',
+    }),
+]
+```
+
+### New (React Dashboard)
+
+```tsx
+import {
+    Button,
+    DashboardRouteDefinition,
+    ListPage,
+    PageActionBarRight,
+    DetailPageButton,
+} from '@vendure/dashboard';
+import { Link } from '@tanstack/react-router';
+import { PlusIcon } from 'lucide-react';
+
+// This function is generated for you by the `vendureDashboardPlugin` in your Vite config.
+// It uses gql-tada to generate TypeScript types which give you type safety as you write
+// your queries and mutations.
+import { graphql } from '@/gql';
+
+// The fields you select here will be automatically used to generate the appropriate columns in the
+// data table below.
+const getArticleList = graphql(`
+    query GetArticles($options: ArticleListOptions) {
+        articles(options: $options) {
+            items {
+                id
+                createdAt
+                updatedAt
+                isPublished
+                title
+                slug
+                body
+                customFields
+            }
+            totalItems
+        }
+    }
+`);
+
+const deleteArticleDocument = graphql(`
+    mutation DeleteArticle($id: ID!) {
+        deleteArticle(id: $id) {
+            result
+        }
+    }
+`);
+
+export const articleList: DashboardRouteDefinition = {
+    navMenuItem: {
+        sectionId: 'catalog',
+        id: 'articles',
+        url: '/articles',
+        title: 'CMS Articles',
+    },
+    path: '/articles',
+    loader: () => ({
+        breadcrumb: 'Articles',
+    }),
+    component: route => (
+        <ListPage
+            pageId="article-list"
+            title="Articles"
+            listQuery={getArticleList}
+            deleteMutation={deleteArticleDocument}
+            route={route}
+            customizeColumns={{
+                title: {
+                    cell: ({ row }) => {
+                        const post = row.original;
+                        return <DetailPageButton id={post.id} label={post.title} />;
+                    },
+                },
+            }}
+            defaultVisibility={{
+                type: true,
+                summary: true,
+                state: true,
+                rating: true,
+                authorName: true,
+                authorLocation: true,
+            }}
+            defaultColumnOrder={[
+                'type',
+                'summary',
+                'authorName',
+                'authorLocation',
+                'rating',
+            ]}
+        >
+            <PageActionBarRight>
+                <Button asChild>
+                    <Link to="./new">
+                        <PlusIcon className="mr-2 h-4 w-4" />
+                        New article
+                    </Link>
+                </Button>
+            </PageActionBarRight>
+        </ListPage>
+    ),
+};
+```
+
+Important:
+    - When using `defaultVisibility`, specify the specific visible ones with `true`. *Do not* mix
+      true and false values. It is implicit that any not specified will default to `false`.
+    - The `id`, `createdAt` and `updatedAt` never need to be specified in `customizeColumns`, defaultVisibility` or `defaultColumnOrder`.
+      They are handled correctly by default.
+    - By default the DataTable will handle column names based on the field name,
+      e.g. `authorName` -> `Author Name`, `rating` -> `Rating`, so an explicit cell header is
+      not needed unless the column header title must significantly differ from the field name.
+    - If a custom `cell` function needs to access fields _other_ than the one being rendered,
+      those other fields *must* be declared as dependencies:
+      ```tsx
+      customizeColumns={{
+        name: {
+          // Note, we DO NOT need to declare "name" as a dependency here,
+          // since we are handling the `name` column already.
+          meta: { dependencies: ['reviewCount'] },
+          cell: ({ row }) => {
+            const { name, reviewCount } = row.original;
+            return <Badge variant="outline">{name} ({reviewCount})</Badge>
+          },
+        },
+      }}
+      ```
+
+## Detail Pages
+
+### Old (Angular)
+```ts
+import { ResultOf } from '@graphql-typed-document-node/core';
+import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from '@angular/core';
+import { FormBuilder } from '@angular/forms';
+import { TypedBaseDetailComponent, LanguageCode, NotificationService, SharedModule } from '@vendure/admin-ui/core';
+
+// This is the TypedDocumentNode & type generated by GraphQL Code Generator
+import { graphql } from '../../gql';
+
+export const reviewDetailFragment = graphql(`
+  fragment ReviewDetail on ProductReview {
+    id
+    createdAt
+    updatedAt
+    title
+    rating
+    text
+    authorName
+    productId
+  }
+`);
+
+export const getReviewDetailDocument = graphql(`
+  query GetReviewDetail($id: ID!) {
+    review(id: $id) {
+      ...ReviewDetail
+    }
+  }
+`);
+
+export const createReviewDocument = graphql(`
+  mutation CreateReview($input: CreateProductReviewInput!) {
+    createProductReview(input: $input) {
+      ...ReviewDetail
+    }
+  }
+`);
+
+export const updateReviewDocument = graphql(`
+  mutation UpdateReview($input: UpdateProductReviewInput!) {
+    updateProductReview(input: $input) {
+      ...ReviewDetail
+    }
+  }
+`);
+
+@Component({
+    selector: 'review-detail',
+    templateUrl: './review-detail.component.html',
+    styleUrls: ['./review-detail.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+    standalone: true,
+    imports: [SharedModule],
+})
+export class ReviewDetailComponent extends TypedBaseDetailComponent<typeof getReviewDetailDocument, 'review'> implements OnInit, OnDestroy {
+    detailForm = this.formBuilder.group({
+        title: [''],
+        rating: [1],
+        authorName: [''],
+    });
+
+    constructor(private formBuilder: FormBuilder, private notificationService: NotificationService) {
+        super();
+    }
+
+    ngOnInit() {
+        this.init();
+    }
+
+    ngOnDestroy() {
+        this.destroy();
+    }
+
+    create() {
+        const { title, rating, authorName } = this.detailForm.value;
+        if (!title || rating == null || !authorName) {
+            return;
+        }
+        this.dataService
+            .mutate(createReviewDocument, {
+                input: { title, rating, authorName },
+            })
+            .subscribe(({ createProductReview }) => {
+                if (createProductReview.id) {
+                    this.notificationService.success('Review created');
+                    this.router.navigate(['extensions', 'reviews', createProductReview.id]);
+                }
+            });
+    }
+
+    update() {
+        const { title, rating, authorName } = this.detailForm.value;
+        this.dataService
+            .mutate(updateReviewDocument, {
+                input: { id: this.id, title, rating, authorName },
+            })
+            .subscribe(() => {
+                this.notificationService.success('Review updated');
+            });
+    }
+
+    protected setFormValues(entity: NonNullable<ResultOf<typeof getReviewDetailDocument>['review']>, languageCode: LanguageCode): void {
+        this.detailForm.patchValue({
+            title: entity.name,
+            rating: entity.rating,
+            authorName: entity.authorName,
+            productId: entity.productId,
+        });
+    }
+}
+```
+
+```html
+<vdr-page-block>
+    <vdr-action-bar>
+        <vdr-ab-left></vdr-ab-left>
+        <vdr-ab-right>
+            <button
+                class="button primary"
+                *ngIf="isNew$ | async; else updateButton"
+                (click)="create()"
+                [disabled]="detailForm.pristine || detailForm.invalid"
+            >
+                {{ 'common.create' | translate }}
+            </button>
+            <ng-template #updateButton>
+                <button
+                    class="btn btn-primary"
+                    (click)="update()"
+                    [disabled]="detailForm.pristine || detailForm.invalid"
+                >
+                    {{ 'common.update' | translate }}
+                </button>
+            </ng-template>
+        </vdr-ab-right>
+    </vdr-action-bar>
+</vdr-page-block>
+
+<form class="form" [formGroup]="detailForm">
+    <vdr-page-detail-layout>
+        <!-- The sidebar is used for displaying "metadata" type information about the entity -->
+        <vdr-page-detail-sidebar>
+            <vdr-card *ngIf="entity$ | async as entity">
+                <vdr-page-entity-info [entity]="entity" />
+            </vdr-card>
+        </vdr-page-detail-sidebar>
+
+        <!-- The main content area is used for displaying the entity's fields -->
+        <vdr-page-block>
+            <!-- The vdr-card is the container for grouping items together on a page -->
+            <!-- it can also take an optional [title] property to display a title -->
+            <vdr-card>
+                <!-- the form-grid class is used to lay out the form fields -->
+                <div class="form-grid">
+                    <vdr-form-field label="Title" for="title">
+                        <input id="title" type="text" formControlName="title" />
+                    </vdr-form-field>
+                    <vdr-form-field label="Rating" for="rating">
+                        <input id="rating" type="number" min="1" max="5" formControlName="rating" />
+                    </vdr-form-field>
+
+                    <!-- etc -->
+                </div>
+            </vdr-card>
+        </vdr-page-block>
+    </vdr-page-detail-layout>
+</form>
+```
+
+```ts
+import { registerRouteComponent } from '@vendure/admin-ui/core';
+
+import { ReviewDetailComponent, getReviewDetailDocument } from './components/review-detail/review-detail.component';
+
+export default [
+    registerRouteComponent({
+        path: ':id',
+        component: ReviewDetailComponent,
+        query: getReviewDetailDocument,
+        entityKey: 'productReview',
+        getBreadcrumbs: entity => [
+            {
+                label: 'Product reviews',
+                link: ['/extensions', 'product-reviews'],
+            },
+            {
+                label: `#${entity?.id} (${entity?.product.name})`,
+                link: [],
+            },
+        ],
+    }),
+]
+```
+
+### New (React Dashboard)
+
+```tsx
+import {
+    DashboardRouteDefinition,
+    detailPageRouteLoader,
+    useDetailPage,
+    Page,
+    PageTitle,
+    PageActionBar,
+    PageActionBarRight,
+    PermissionGuard,
+    Button,
+    PageLayout,
+    PageBlock,
+    FormFieldWrapper,
+    DetailFormGrid,
+    Switch,
+    Input,
+    RichTextInput,
+    CustomFieldsPageBlock,
+} from '@vendure/dashboard';
+import { AnyRoute, useNavigate } from '@tanstack/react-router';
+import { toast } from 'sonner';
+
+import { graphql } from '@/gql';
+
+const articleDetailDocument = graphql(`
+    query GetArticleDetail($id: ID!) {
+        article(id: $id) {
+            id
+            createdAt
+            updatedAt
+            isPublished
+            title
+            slug
+            body
+            customFields
+        }
+    }
+`);
+
+const createArticleDocument = graphql(`
+    mutation CreateArticle($input: CreateArticleInput!) {
+        createArticle(input: $input) {
+            id
+        }
+    }
+`);
+
+const updateArticleDocument = graphql(`
+    mutation UpdateArticle($input: UpdateArticleInput!) {
+        updateArticle(input: $input) {
+            id
+        }
+    }
+`);
+
+export const articleDetail: DashboardRouteDefinition = {
+    path: '/articles/$id',
+    loader: detailPageRouteLoader({
+        queryDocument: articleDetailDocument,
+        breadcrumb: (isNew, entity) => [
+            { path: '/articles', label: 'Articles' },
+            isNew ? 'New article' : entity?.title,
+        ],
+    }),
+    component: route => {
+        return <ArticleDetailPage route={route} />;
+    },
+};
+
+function ArticleDetailPage({ route }: { route: AnyRoute }) {
+const params = route.useParams();
+const navigate = useNavigate();
+const creatingNewEntity = params.id === 'new';
+
+    const { form, submitHandler, entity, isPending, resetForm, refreshEntity } = useDetailPage({
+        queryDocument: articleDetailDocument,
+        createDocument: createArticleDocument,
+        updateDocument: updateArticleDocument,
+        setValuesForUpdate: article => {
+            return {
+                id: article?.id ?? '',
+                isPublished: article?.isPublished ?? false,
+                title: article?.title ?? '',
+                slug: article?.slug ?? '',
+                body: article?.body ?? '',
+            };
+        },
+        params: { id: params.id },
+        onSuccess: async data => {
+            toast.success('Successfully updated article');
+            resetForm();
+            if (creatingNewEntity) {
+                await navigate({ to: `../$id`, params: { id: data.id } });
+            }
+        },
+        onError: err => {
+            toast.error('Failed to update article', {
+                description: err instanceof Error ? err.message : 'Unknown error',
+            });
+        },
+    });
+
+    return (
+        <Page pageId="article-detail" form={form} submitHandler={submitHandler}>
+            <PageTitle>{creatingNewEntity ? 'New article' : (entity?.title ?? '')}</PageTitle>
+            <PageActionBar>
+                <PageActionBarRight>
+                    <PermissionGuard requires={['UpdateProduct', 'UpdateCatalog']}>
+                        <Button
+                            type="submit"
+                            disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
+                        >
+                            Update
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
+            </PageActionBar>
+            <PageLayout>
+                <PageBlock column="side" blockId="publish-status" title="Status" description="Current status of this article">
+                    <FormFieldWrapper
+                        control={form.control}
+                        name="isPublished"
+                        label="Is Published"
+                        render={({ field }) => (
+                            <Switch checked={field.value} onCheckedChange={field.onChange} />
+                        )}
+                    />
+                </PageBlock>
+                <PageBlock column="main" blockId="main-form">
+                    <DetailFormGrid>
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="title"
+                            label="Title"
+                            render={({ field }) => <Input {...field} />}
+                        />
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="slug"
+                            label="Slug"
+                            render={({ field }) => <Input {...field} />}
+                        />
+                    </DetailFormGrid>
+                    <div className="space-y-6">
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="body"
+                            label="Content"
+                            render={({ field }) => (
+                                <RichTextInput value={field.value ?? ''} onChange={field.onChange} />
+                            )}
+                        />
+                    </div>
+                </PageBlock>
+                <CustomFieldsPageBlock column="main" entityType="Article" control={form.control} />
+            </PageLayout>
+        </Page>
+    );
+}
+```
+
+Important:
+    - The PageBlock component should *never* contain any Card-like component, because it already
+      renders like a card.
+    - Use `refreshEntity` to trigger a manual reload of the entity data (e.g. after a mutation
+      succeeds)
+    - The `DetailFormGrid` has a built-in `mb-6`, but for components not wrapped in this,
+      manually ensure there is a y gap of 6 (e.g. wrap in `<div className="space-y-6">`)
+
+## Adding Nav Menu Items
+
+### Old (Angular)
+```ts
+import { addNavMenuSection } from '@vendure/admin-ui/core';
+
+export default [
+    addNavMenuSection({
+        id: 'greeter',
+        label: 'My Extensions',
+        items: [{
+            id: 'greeter',
+            label: 'Greeter',
+            routerLink: ['/extensions/greet'],
+            // Icon can be any of https://core.clarity.design/foundation/icons/shapes/
+            icon: 'cursor-hand-open',
+        }],
+    },
+    // Add this section before the "settings" section
+    'settings'),
+];
+```
+
+### New (React Dashboard)
+
+```tsx
+import { defineDashboardExtension } from '@vendure/dashboard';
+
+defineDashboardExtension({
+    routes: [
+        {
+            path: '/my-custom-page',
+            component: () => <div>My Custom Page</div>,
+            navMenuItem: {
+                // The section where this item should appear
+                sectionId: 'catalog',
+                // Unique identifier for this menu item
+                id: 'my-custom-page',
+                // Display text in the navigation
+                title: 'My Custom Page',
+                // Optional: URL if different from path
+                url: '/my-custom-page',
+            },
+        },
+    ],
+});
+```
+
+## Action Bar Items
+
+### Old (Angular)
+```ts
+import { addActionBarItem } from '@vendure/admin-ui/core';
+
+export default [
+    addActionBarItem({
+        id: 'print-invoice',
+        locationId: 'order-detail',
+        label: 'Print invoice',
+        icon: 'printer',
+        routerLink: route => {
+            const id = route.snapshot.params.id;
+            return ['./extensions/order-invoices', id];
+        },
+        requiresPermission: 'ReadOrder',
+    }),
+];
+```
+
+### New (React Dashboard)
+
+```tsx
+import { Button, defineDashboardExtension } from '@vendure/dashboard';
+import { useState } from 'react';
+
+defineDashboardExtension({
+    actionBarItems: [
+        {
+            pageId: 'product-detail',
+            component: ({ context }) => {
+                const [count, setCount] = useState(0);
+                return (
+                    <Button type="button" variant="secondary" onClick={() => setCount(x => x + 1)}>
+                        Counter: {count}
+                    </Button>
+                );
+            },
+        },
+    ],
+});
+```
+
+## Custom Detail Components
+
+### Old (Angular)
+```ts title="src/plugins/cms/ui/components/product-info/product-info.component.ts"
+import { Component, OnInit } from '@angular/core';
+import { Observable, switchMap } from 'rxjs';
+import { FormGroup } from '@angular/forms';
+import { DataService, CustomDetailComponent, SharedModule } from '@vendure/admin-ui/core';
+import { CmsDataService } from '../../providers/cms-data.service';
+
+@Component({
+    template: `
+        <vdr-card title="CMS Info">
+            <pre>{{ extraInfo$ | async | json }}</pre>
+        </vdr-card>`,
+    standalone: true,
+    providers: [CmsDataService],
+    imports: [SharedModule],
+})
+export class ProductInfoComponent implements CustomDetailComponent, OnInit {
+    // These two properties are provided by Vendure and will vary
+    // depending on the particular detail page you are embedding this
+    // component into. In this case, it will be a "product" entity.
+    entity$: Observable<any>
+    detailForm: FormGroup;
+
+    extraInfo$: Observable<any>;
+
+    constructor(private cmsDataService: CmsDataService) {
+    }
+
+    ngOnInit() {
+        this.extraInfo$ = this.entity$.pipe(
+            switchMap(entity => this.cmsDataService.getDataFor(entity.id))
+        );
+    }
+}
+```
+
+### New (React Dashboard)
+
+```tsx title="src/plugins/my-plugin/dashboard/index.tsx"
+import { defineDashboardExtension } from '@vendure/dashboard';
+
+defineDashboardExtension({
+    pageBlocks: [
+        {
+            id: 'related-articles',
+            title: 'Related Articles',
+            location: {
+                // This is the pageId of the page where this block will be
+                pageId: 'product-detail',
+                // can be "main" or "side"
+                column: 'side',
+                position: {
+                    // Blocks are positioned relative to existing blocks on
+                    // the page.
+                    blockId: 'facet-values',
+                    // Can be "before", "after" or "replace"
+                    // Here we'll place it after the `facet-values` block.
+                    order: 'after',
+                },
+            },
+            component: ({ context }) => {
+                // In the component, you can use the `context` prop to
+                // access the entity and the form instance.
+                return <div className="text-sm">Articles related to {context.entity.name}</div>;
+            },
+        },
+    ],
+});
+```
+
+## Page Tabs
+
+### Old (Angular)
+```ts
+import { registerPageTab } from '@vendure/admin-ui/core';
+
+import { ReviewListComponent } from './components/review-list/review-list.component';
+
+export default [
+    registerPageTab({
+        location: 'product-detail',
+        tab: 'Reviews',
+        route: 'reviews',
+        tabIcon: 'star',
+        component: ReviewListComponent,
+    }),
+];
+```
+
+### New (React Dashboard)
+
+Page tabs are not supported by the Dashboard. Suggest alternative such as a new route.
+
+## Widgets
+
+### Old (Angular)
+```ts title="src/plugins/reviews/ui/components/reviews-widget/reviews-widget.component.ts"
+import { Component, OnInit } from '@angular/core';
+import { DataService, SharedModule } from '@vendure/admin-ui/core';
+import { Observable } from 'rxjs';
+
+@Component({
+    selector: 'reviews-widget',
+    template: `
+        <ul>
+            <li *ngFor="let review of pendingReviews$ | async">
+                <a [routerLink]="['/extensions', 'product-reviews', review.id]">{{ review.summary }}</a>
+                <span class="rating">{{ review.rating }} / 5</span>
+            </li>
+        </ul>
+    `,
+    standalone: true,
+    imports: [SharedModule],
+})
+export class ReviewsWidgetComponent implements OnInit {
+    pendingReviews$: Observable<any[]>;
+
+    constructor(private dataService: DataService) {}
+
+    ngOnInit() {
+        this.pendingReviews$ = this.dataService.query(gql`
+            query GetAllReviews($options: ProductReviewListOptions) {
+                productReviews(options: $options) {
+                    items {
+                        id
+                        createdAt
+                        authorName
+                        summary
+                        rating
+                    }
+                }
+            }`, {
+                options: {
+                    filter: { state: { eq: 'new' } },
+                    take: 10,
+                },
+            })
+            .mapStream(data => data.productReviews.items);
+    }
+}
+```
+
+```ts title="src/plugins/reviews/ui/providers.ts"
+import { registerDashboardWidget } from '@vendure/admin-ui/core';
+
+export default [
+    registerDashboardWidget('reviews', {
+        title: 'Latest reviews',
+        supportedWidths: [4, 6, 8, 12],
+        requiresPermissions: ['ReadReview'],
+        loadComponent: () =>
+            import('./reviews-widget/reviews-widget.component').then(
+                m => m.ReviewsWidgetComponent,
+            ),
+    }),
+];
+```
+
+### New (React Dashboard)
+
+```tsx title="custom-widget.tsx"
+import { Badge, DashboardBaseWidget, useLocalFormat, useWidgetFilters } from '@vendure/dashboard';
+
+export function CustomWidget() {
+    const { dateRange } = useWidgetFilters();
+    const { formatDate } = useLocalFormat();
+    return (
+        <DashboardBaseWidget id="custom-widget" title="Custom Widget" description="This is a custom widget">
+            <div className="flex flex-wrap gap-1">
+                <span>Displaying results from</span>
+                <Badge variant="secondary">{formatDate(dateRange.from)}</Badge>
+                <span>to</span>
+                <Badge variant="secondary">{formatDate(dateRange.to)}</Badge>
+            </div>
+        </DashboardBaseWidget>
+    );
+}
+```
+
+```tsx title="index.tsx"
+import { defineDashboardExtension } from '@vendure/dashboard';
+
+import { CustomWidget } from './custom-widget';
+
+defineDashboardExtension({
+    widgets: [
+        {
+            id: 'custom-widget',
+            name: 'Custom Widget',
+            component: CustomWidget,
+            defaultSize: { w: 3, h: 3 },
+        },
+    ],
+});
+```
+````
+
+</div>
+
+:::tip
+The full prompt is quite large, so it can make sense to first clear the current LLM context,
+e.g. with `/clear` in Claude Code or `/new` in Codex CLI
+:::
+
+### Claude Skills
+
+If you use Claude Code, you can use [Agent Skills](https://docs.claude.com/en/docs/agents-and-tools/agent-skills/overview) to set
+up a specialized skill for migrating plugins. This has the advantage that you do not need to continually paste in the full prompt,
+and it can also be potentially more token-efficient.
+
+To set up a the skill, run this from the root of your project:
+
+```
+npx degit vendure-ecommerce/vendure/.claude/skills#minor .claude/skills
+```
+
+This command uses [degit](https://github.com/Rich-Harris/degit) to copy over the vendure-dashboard-migration skill to 
+your local `./claude/skills` directory.
+
+You can then have Claude Code use the skill with a prompt like:
+
+```
+Use the vendure-dashboard-migration skill to migrate 
+@src/plugins/my-plugin to use the dashboard
+```
+
+:::note
+The individual files in the skill contain the exact same content as the full prompt above,
+but are more easily reused and can be more token-efficient
+:::
+
+### Manual Cleanup
+
+It is very likely you'll still need to do _some_ manual cleanup after an AI-assisted migration. You might run into
+things like:
+
+- Non-optimum styling choices
+- Issues with the [tsconfig setup](/guides/extending-the-dashboard/getting-started/#installation--setup) not being perfectly implemented.
+- For more complex repo structures like a monorepo with plugins as separate libs, you may need to manually implement
+  the initial setup of the config files.
+
+## Manual Migration
+
+If you would rather do a full manual migration, you should first follow the [Dashboard Getting Started guide](/guides/extending-the-dashboard/getting-started/)
+and the [Extending the Dashboard guide](http://localhost:3001/guides/extending-the-dashboard/extending-overview/).
+
+The remainder of this document details specific features, and how they are now implemented in the new Dashboard.
+
+### Forms
+
+Forms in the Angular Admin UI used `vdr-form-field` components within a `form-grid` class. In the Dashboard, forms use `FormFieldWrapper` with react-hook-form, wrapped in either `DetailFormGrid` for grid layouts or div containers with `space-y-6` for vertical spacing.
+
+| Admin UI | Dashboard | Imported From | Notes |
+|----------|-----------|---------------|-------|
+| `vdr-form-field` | `FormFieldWrapper` | `@vendure/dashboard` | Uses react-hook-form |
+| `form-grid` (class) | `DetailFormGrid` | `@vendure/dashboard` | For grid layouts |
+| `vdr-rich-text-editor` | `RichTextInput` | `@vendure/dashboard` | |
+| - | `Input` | `@vendure/dashboard` | Basic text input |
+| `FormGroup` | `useForm` | `react-hook-form` | Form state management |
+
+```tsx
+<PageBlock column="main" blockId="main-form">
+    <DetailFormGrid>
+        <FormFieldWrapper
+            control={form.control}
+            name="title"
+            label="Title"
+            render={({ field }) => <Input {...field} />}
+        />
+    </DetailFormGrid>
+    <div className="space-y-6">
+        <FormFieldWrapper
+            control={form.control}
+            name="body"
+            label="Content"
+            render={({ field }) => (
+                <RichTextInput value={field.value ?? ''} onChange={field.onChange} />
+            )}
+        />
+    </div>
+</PageBlock>
+```
+
+### Custom Field Inputs
+
+Custom field inputs now use the `DashboardFormComponent` type and are registered via `customFormComponents.customFields` in the Dashboard extension definition. Components receive `value`, `onChange`, and `name` props, and can use `useFormContext()` to access field state and errors.
+
+| Admin UI | Dashboard | Imported From | Notes |
+|----------|-----------|---------------|-------|
+| `FormInputComponent<T>` | `DashboardFormComponent` | `@vendure/dashboard` | Type for custom field components |
+| `registerFormInputComponent()` | `customFormComponents.customFields` | `@vendure/dashboard` | Registration method |
+| `formControl` (prop) | `value`, `onChange`, `name` (props) | - | Component receives these props |
+| - | `useFormContext()` | `react-hook-form` | Access field state and errors |
+
+```tsx
+export const ColorPickerComponent: DashboardFormComponent = ({ value, onChange, name }) => {
+    const { getFieldState } = useFormContext();
+    const error = getFieldState(name).error;
+
+    return (
+        <Input value={value || ''} onChange={e => onChange(e.target.value)} />
+    );
+};
+
+// Register in index.tsx
+defineDashboardExtension({
+    customFormComponents: {
+        customFields: [
+            { id: 'color-picker', component: ColorPickerComponent },
+        ],
+    },
+});
+```
+
+### List Pages
+
+List pages migrate from `TypedBaseListComponent` to the `ListPage` component. The `ListPage` automatically generates columns from the GraphQL query fields. Use `customizeColumns` to customize specific columns (e.g., linking with `DetailPageButton`), `defaultVisibility` to control which columns show by default, and `defaultColumnOrder` to set column order.
+
+| Admin UI | Dashboard | Imported From | Notes |
+|----------|-----------|---------------|-------|
+| `TypedBaseListComponent` | `ListPage` | `@vendure/dashboard` | Main list component |
+| `vdr-data-table-2` | `ListPage` | `@vendure/dashboard` | Auto-generates columns |
+| `vdr-dt2-column` | `customizeColumns` | - | Prop on `ListPage` |
+| `[hiddenByDefault]` | `defaultVisibility` | - | Prop on `ListPage` |
+| `registerRouteComponent()` | `DashboardRouteDefinition` | `@vendure/dashboard` | Route registration |
+| `[routerLink]` | `DetailPageButton` | `@vendure/dashboard` | For linking to detail pages |
+
+```tsx
+export const articleList: DashboardRouteDefinition = {
+    path: '/articles',
+    component: route => (
+        <ListPage
+            pageId="article-list"
+            title="Articles"
+            listQuery={getArticleList}
+            deleteMutation={deleteArticleDocument}
+            route={route}
+            customizeColumns={{
+                title: {
+                    cell: ({ row }) => <DetailPageButton id={row.original.id} label={row.original.title} />,
+                },
+            }}
+            defaultVisibility={{
+                title: true,
+                authorName: true,
+            }}
+        >
+            <PageActionBarRight>
+                <Button asChild>
+                    <Link to="./new"><PlusIcon /> New article</Link>
+                </Button>
+            </PageActionBarRight>
+        </ListPage>
+    ),
+};
+```
+
+**Important**: When using `defaultVisibility`, only specify visible columns with `true`. The `id`, `createdAt`, and `updatedAt` columns are handled automatically. If a custom `cell` function accesses fields other than the one being rendered, declare them in `meta.dependencies`.
+
+### Detail Pages
+
+Detail pages migrate from `TypedBaseDetailComponent` to the `useDetailPage` hook. The hook handles form initialization, entity loading, and mutations. Use `detailPageRouteLoader` for the route loader, and structure the page with `Page`, `PageActionBar`, `PageLayout`, `PageBlock`, and `DetailFormGrid` components.
+
+| Admin UI | Dashboard | Imported From | Notes |
+|----------|-----------|---------------|-------|
+| `TypedBaseDetailComponent` | `useDetailPage()` | `@vendure/dashboard` | Hook for detail page logic |
+| `this.init()` | `useDetailPage()` | `@vendure/dashboard` | Automatic initialization |
+| `this.entity$` | `entity` | - | Returned from `useDetailPage` |
+| `FormBuilder` | `form` | - | Returned from `useDetailPage` |
+| `dataService.mutate()` | `submitHandler` | - | Returned from `useDetailPage` |
+| `vdr-page-detail-layout` | `PageLayout` | `@vendure/dashboard` | Layout component |
+| `vdr-page-block` | `PageBlock` | `@vendure/dashboard` | Content block |
+| `registerRouteComponent()` | `detailPageRouteLoader()` | `@vendure/dashboard` | Route loader helper |
+
+```tsx
+export const articleDetail: DashboardRouteDefinition = {
+    path: '/articles/$id',
+    loader: detailPageRouteLoader({
+        queryDocument: articleDetailDocument,
+        breadcrumb: (isNew, entity) => [
+            { path: '/articles', label: 'Articles' },
+            isNew ? 'New article' : entity?.title,
+        ],
+    }),
+    component: route => {
+        const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
+            queryDocument: articleDetailDocument,
+            createDocument: createArticleDocument,
+            updateDocument: updateArticleDocument,
+            setValuesForUpdate: article => ({
+                title: article?.title ?? '',
+                slug: article?.slug ?? '',
+            }),
+            params: { id: route.useParams().id },
+            onSuccess: async data => {
+                toast.success('Successfully updated');
+            },
+        });
+
+        return (
+            <Page pageId="article-detail" form={form} submitHandler={submitHandler}>
+                <PageLayout>
+                    <PageBlock column="main" blockId="main-form">
+                        <DetailFormGrid>
+                            <FormFieldWrapper control={form.control} name="title" label="Title"
+                                render={({ field }) => <Input {...field} />} />
+                        </DetailFormGrid>
+                    </PageBlock>
+                </PageLayout>
+            </Page>
+        );
+    },
+};
+```
+
+**Important**: `PageBlock` already renders as a card, so never nest Card components inside it. Use `refreshEntity` to manually reload entity data after mutations. Ensure vertical spacing of 6 units for components not in `DetailFormGrid`.
+
+### Nav Menu Items
+
+Nav menu items are now configured via the `navMenuItem` property on route definitions within the `routes` array. Specify `sectionId` (e.g., 'catalog'), unique `id`, and `title`.
+
+| Admin UI | Dashboard | Imported From | Notes |
+|----------|-----------|---------------|-------|
+| `addNavMenuSection()` | `navMenuItem` | - | Defined on route in `routes` array |
+| `label` | `title` | - | Display text |
+| `routerLink` | `path` | - | Route path |
+| `icon` | - | - | Not supported in Dashboard |
+
+```tsx
+defineDashboardExtension({
+    routes: [
+        {
+            path: '/my-custom-page',
+            component: () => <div>My Custom Page</div>,
+            navMenuItem: {
+                sectionId: 'catalog',
+                id: 'my-custom-page',
+                title: 'My Custom Page',
+            },
+        },
+    ],
+});
+```
+
+### Action Bar Items
+
+Action bar items migrate from `addActionBarItem` to the `actionBarItems` array in the Dashboard extension. Each item specifies a `pageId` and a `component` function that receives `context`.
+
+| Admin UI | Dashboard | Imported From | Notes |
+|----------|-----------|---------------|-------|
+| `addActionBarItem()` | `actionBarItems` | - | Array in `defineDashboardExtension` |
+| `locationId` | `pageId` | - | Identifies target page |
+| `label` | - | - | Render button/component directly |
+| `icon` | - | `lucide-react` | Use icon components in button |
+| `routerLink` | `Link` / `useNavigate()` | `@tanstack/react-router` | For navigation |
+
+```tsx
+defineDashboardExtension({
+    actionBarItems: [
+        {
+            pageId: 'product-detail',
+            component: ({ context }) => (
+                <Button type="button" variant="secondary" onClick={() => handleAction()}>
+                    Custom Action
+                </Button>
+            ),
+        },
+    ],
+});
+```
+
+### Custom Detail Components (Page Blocks)
+
+Custom detail components (Angular `CustomDetailComponent`) are now implemented as page blocks via the `pageBlocks` array. Each block specifies `id`, `title`, `location` (pageId, column, position), and a `component` function that receives `context` with `entity` and form access.
+
+| Admin UI | Dashboard | Imported From | Notes |
+|----------|-----------|---------------|-------|
+| `CustomDetailComponent` | `pageBlocks` | - | Array in `defineDashboardExtension` |
+| `entity$` (Observable) | `context.entity` | - | Available in component function |
+| `detailForm` | `context.form` | - | Available in component function |
+| `registerCustomDetailComponent()` | `pageBlocks[].location` | - | Positioning configuration |
+
+```tsx
+defineDashboardExtension({
+    pageBlocks: [
+        {
+            id: 'related-articles',
+            title: 'Related Articles',
+            location: {
+                pageId: 'product-detail',
+                column: 'side',
+                position: { blockId: 'facet-values', order: 'after' },
+            },
+            component: ({ context }) => (
+                <div>Articles related to {context.entity.name}</div>
+            ),
+        },
+    ],
+});
+```
+
+### Page Tabs
+
+Page tabs (`registerPageTab`) are not supported in the Dashboard. Consider alternative approaches such as creating a new route or using page blocks.
+
+| Admin UI | Dashboard | Imported From | Notes |
+|----------|-----------|---------------|-------|
+| `registerPageTab()` | - | - | Not supported; use routes or page blocks instead |
+
+### Widgets
+
+Dashboard widgets migrate from `registerDashboardWidget` to the `widgets` array. Each widget specifies `id`, `name`, `component`, and `defaultSize`. Widget components can use `useWidgetFilters()` and `useLocalFormat()` hooks, and should wrap content in `DashboardBaseWidget`.
+
+| Admin UI | Dashboard | Imported From | Notes |
+|----------|-----------|---------------|-------|
+| `registerDashboardWidget()` | `widgets` | - | Array in `defineDashboardExtension` |
+| `title` | `name` | - | Widget display name |
+| `loadComponent` | `component` | - | Widget component function |
+| `supportedWidths` | `defaultSize` | - | Object with `w` and `h` properties |
+| - | `DashboardBaseWidget` | `@vendure/dashboard` | Wrapper component for widgets |
+| - | `useWidgetFilters()` | `@vendure/dashboard` | Access date range filters |
+| - | `useLocalFormat()` | `@vendure/dashboard` | Formatting utilities |
+
+```tsx
+export function CustomWidget() {
+    const { dateRange } = useWidgetFilters();
+    const { formatDate } = useLocalFormat();
+    return (
+        <DashboardBaseWidget id="custom-widget" title="Custom Widget" description="Widget description">
+            <div>
+                <Badge variant="secondary">{formatDate(dateRange.from)}</Badge>
+                to
+                <Badge variant="secondary">{formatDate(dateRange.to)}</Badge>
+            </div>
+        </DashboardBaseWidget>
+    );
+}
+
+defineDashboardExtension({
+    widgets: [
+        { id: 'custom-widget', name: 'Custom Widget', component: CustomWidget, defaultSize: { w: 3, h: 3 } },
+    ],
+});
+```
+
+

+ 195 - 0
docs/docs/guides/how-to/multi-vendor-marketplaces/index.mdx

@@ -0,0 +1,195 @@
+---
+title: "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 our [example multi-vendor plugin](https://github.com/vendurehq/vendure/tree/master/packages/dev-server/example-plugins/multivendor-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.
+
+:::caution
+**Note:** the [example multi-vendor plugin](https://github.com/vendurehq/vendure/tree/master/packages/dev-server/example-plugins/multivendor-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.
+:::
+
+![The Dashboard Aggregate Order screen](./aggregate-order.webp)
+
+## Sellers, Channels & Roles
+
+The core of Vendure's multi-vendor support is Channels. Read the [Channels guide](/guides/core-concepts/channels/) to get a more detailed understanding of how they work.
+
+Each Channel is assigned to a [Seller](/reference/typescript-api/entities/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 title="Shop API"
+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](/reference/typescript-api/entities/seller/) representing the shop "Bob's Parts"
+- Create a new [Channel](/reference/typescript-api/entities/channel) and associate it with the new Seller
+- Create a [Role](/reference/typescript-api/entities/role) & [Administrator](/reference/typescript-api/entities/administrator) for Bob to access his shop admin account
+- Create a [ShippingMethod](/reference/typescript-api/entities/shipping-method) for Bob's shop
+- Create a [StockLocation](/reference/typescript-api/entities/stock-location) for Bob's shop
+
+Bob can now log in to the Dashboard using the provided credentials and begin creating products to sell!
+
+### Keeping prices synchronized
+
+In some marketplaces, the same product may be sold by multiple sellers. When this is the case, the product and its variants
+will be assigned not only to the default channel, but to multiple other channels as well - see the 
+[Channels, Currencies & Prices section](/guides/core-concepts/channels/#channels-currencies--prices) for a visual explanation of how this works.
+
+This means that there will be multiple ProductVariantPrice entities per variant, one for each channel. 
+ 
+In order
+to keep prices synchronized across all channels, the example multi-vendor plugin sets the `syncPricesAcrossChannels` property
+of the [DefaultProductVariantPriceUpdateStrategy](/reference/typescript-api/configuration/product-variant-price-update-strategy#defaultproductvariantpriceupdatestrategy)
+to `true`. Your own multi-vendor implementation may require more sophisticated price synchronization logic, in which case
+you can implement your own custom [ProductVariantPriceUpdateStrategy](/reference/typescript-api/configuration/product-variant-price-update-strategy).
+
+## 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](/reference/typescript-api/orders/order-seller-strategy/) API, and specifically the `setOrderLineSellerChannel()` method.
+
+The following logic will run any time the `addItemToOrder` mutation is executed from our storefront:
+
+```ts
+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](/reference/typescript-api/shipping/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:
+
+```ts
+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](/reference/typescript-api/shipping/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:
+
+```ts
+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](/reference/typescript-api/orders/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](/reference/typescript-api/orders/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.

+ 229 - 0
docs/docs/guides/storefront/order-workflow/index.mdx

@@ -0,0 +1,229 @@
+---
+title: "Order Workflow"
+showtoc: true
+---
+
+An Order is a collection of one or more ProductVariants which can be purchased by a Customer. Orders are represented internally by the [Order entity](/reference/typescript-api/entities/order/) and in the GraphQL API by the [Order type](/reference/graphql-api/admin/enums/#ordertype).
+
+## Order State
+
+Every Order has a `state` property of type [`OrderState`](/reference/typescript-api/orders/order-process/#orderstate). The following diagram shows the default states and how an Order transitions from one to the next.
+
+:::note
+Note that this default workflow can be modified to better fit your business processes. See the [Customizing the Order Process guide](/guides/core-concepts/orders/#custom-order-processes).
+:::
+
+![./order_state_diagram.png](./order_state_diagram.png)
+
+## Structure of an Order
+
+In Vendure an [Order](/reference/typescript-api/entities/order) consists of one or more [OrderLines](/reference/typescript-api/entities/order-line) (representing a given quantity of a particular SKU).
+
+Here is a simplified diagram illustrating this relationship:
+
+![./order_class_diagram.png](./order_class_diagram.png)
+
+## Shop client order workflow
+
+The [GraphQL Shop API Guide](/guides/storefront/active-order) lists the GraphQL operations you will need to implement this workflow in your storefront client application.
+
+In this section, we'll cover some examples of how these operations would look in your storefront.
+
+### Manipulating the Order
+
+First, let's define a fragment for our Order that we can re-use in subsequent operations:
+
+```graphql
+fragment ActiveOrder on Order {
+  id
+  code
+  state
+  couponCodes
+  subTotalWithTax
+  shippingWithTax
+  totalWithTax
+  totalQuantity
+  lines {
+    id
+    productVariant {
+      id
+      name
+    }
+    featuredAsset {
+      id
+      preview
+    }
+    quantity
+    linePriceWithTax
+  }
+}
+```
+
+Then we can add an item to the Order:
+
+```graphql
+mutation AddItemToOrder($productVariantId: ID! $quantity: Int!){
+  addItemToOrder(productVariantId: $productVariantId, quantity: $quantity) {
+    ... ActiveOrder
+    ... on ErrorResult {
+      errorCode
+      message
+    }
+  }
+}
+```
+
+To remove an item from the order
+
+```graphql
+mutation RemoveItemFromOrder($orderLineId: ID!){
+  removeOrderLine(orderLineId: $orderLineId) {
+    ... ActiveOrder
+    ... on ErrorResult {
+      errorCode
+      message
+    }
+  }
+}
+```
+
+To alter the quantity of an existing OrderLine
+
+```graphql
+mutation AdjustOrderLine($orderLineId: ID! $quantity: Int!){
+  adjustOrderLine(orderLineId: $orderLineId, quantity: $quantity) {
+    ... ActiveOrder
+    ... on ErrorResult {
+      errorCode
+      message
+    }
+  }
+}
+```
+
+At any time we can query the contents of the active Order:
+
+```graphql
+query ActiveOrder {
+  activeOrder {
+    ... ActiveOrder
+  }  
+}
+```
+
+### Checking out
+
+During the checkout process, we'll need to make sure a Customer is assigned to the Order. If the Customer is already signed in, then this can be skipped since Vendure will have already assigned them. If not, then you'd execute:
+
+```graphql
+mutation SetCustomerForOrder($input: CreateCustomerInput!){
+  setCustomerForOrder(input: $input) {
+    ... ActiveOrder
+    ... on ErrorResult {
+      errorCode
+      message
+    }
+  }
+}
+```
+
+Then we need to set the shipping address:
+
+```graphql
+mutation SetShippingAddress($input: CreateAddressInput!){
+  setOrderShippingAddress(input: $input) {
+    ... ActiveOrder
+    ... on ErrorResult {
+      errorCode
+      message
+    }
+  }
+}
+```
+
+Once the shipping address is set, we can find out which ShippingMethods can be used on this Order:
+
+```graphql
+query GetShippingMethods{
+  eligibleShippingMethods {
+    id
+    name
+    code
+    description
+    priceWithTax
+  }
+}
+```
+
+The Customer can then choose one of the available ShippingMethods, and we then set it on the Order:
+
+```graphql
+mutation SetShippingMethod($shippingMethodId: ID!){
+  setOrderShippingMethod(shippingMethodId: $shippingMethodId) {
+    ... ActiveOrder
+    ... on ErrorResult {
+      errorCode
+      message
+    }
+  }
+}
+```
+
+We can now do the same for PaymentMethods:
+
+```graphql
+query GetPaymentMethods{
+  eligiblePaymentMethods {
+    id
+    name
+    code
+    description
+    isEligible
+    eligibilityMessage
+  }
+}
+```
+
+Once the customer is ready to pay, we need to transition the Order to the `ArrangingPayment` state. In this state, no further modifications are permitted. If you _do_ need to modify the Order contents, you can always transition back to the `AddingItems` state:
+
+```graphql
+mutation TransitionOrder($state: String!){
+  transitionOrderToState(state: $state) {
+    ... ActiveOrder
+    ... on ErrorResult {
+      errorCode
+      message
+    }
+  }
+}
+```
+
+Finally, add a Payment to the Order:
+
+```graphql
+mutation AddPayment($input: PaymentInput!){
+  addPaymentToOrder(input: $input) {
+    ... ActiveOrder
+    ... on ErrorResult {
+      errorCode
+      message
+    }
+  }
+}
+```
+
+If the Payment is successful, the Order will now be complete. You can forward the Customer to a confirmation page using the Order's `code`:
+
+```graphql
+query OrderByCode($code: String!) {
+  orderByCode(code: $code) {
+    ...ActiveOrder
+  }
+}
+```
+
+## ActiveOrderStrategy
+
+In the above examples, the active Order is always associated with the current session and is therefore implicit - which is why there is no need to pass an ID to each of the above operations.
+
+Sometimes you _do_ want to be able to explicitly specify the Order you wish to operate on. In this case, you need to define a custom [ActiveOrderStrategy](/reference/typescript-api/orders/active-order-strategy).

+ 11 - 0
docs/docs/reference/admin-ui-api/index.mdx

@@ -0,0 +1,11 @@
+---
+title: "Admin UI API"
+weight: 10
+showtoc: false
+---
+
+These APIs are used when building your own custom extensions to the Admin UI provided by the AdminUiPlugin.
+
+:::note
+All documentation in this section is auto-generated from the TypeScript & HTML source of the Vendure Admin UI package.
+:::

+ 14 - 0
docs/docs/reference/graphql-api/_index.mdx

@@ -0,0 +1,14 @@
+---
+title: "GraphQL API"
+weight: 3
+showtoc: false
+---
+
+This section contains a description of all queries, mutations and related types available in the Vendure GraphQL API.
+
+The API is split into two distinct endpoints: *Shop* and *Admin*. The Shop API is for storefront client applications, whereas the Admin API is used for administrative tasks.
+
+:::note
+All documentation in this section is auto-generated from the Vendure GraphQL schema.
+:::
+

+ 11 - 0
docs/docs/reference/graphql-api/admin/_index.mdx

@@ -0,0 +1,11 @@
+---
+title: "Admin API"
+weight: 4
+showtoc: false
+---
+
+The Admin API is primarily used by the included Admin UI web app to perform administrative tasks such as inventory management, order tracking etc.
+
+:::note
+Explore the interactive GraphQL Admin API at [demo.vendure.io/admin-api](https://demo.vendure.io/admin-api)
+:::

+ 11 - 0
docs/docs/reference/graphql-api/shop/_index.mdx

@@ -0,0 +1,11 @@
+---
+title: "Shop API"
+weight: 3
+showtoc: false
+---
+
+The Shop API is used by storefront applications. It provides all the necessary queries and mutations for finding and viewing products, creating and updating orders, checking out, managing a customer account etc.
+
+:::note
+Explore the interactive GraphQL Shop API at [demo.vendure.io/shop-api](https://demo.vendure.io/shop-api)
+:::

+ 11 - 0
docs/docs/reference/typescript-api/_index.mdx

@@ -0,0 +1,11 @@
+---
+title: "TypeScript API"
+weight: 9
+showtoc: false
+---
+
+The Vendure TypeScript API is used when configuring the server (via the [`VendureConfig`](/reference/typescript-api/configuration/vendure-config/#vendureconfig) object) and when writing plugins that extend the functionality of Vendure core.
+
+:::note
+All documentation in this section is auto-generated from the TypeScript source of the Vendure server.
+:::

+ 25 - 0
docs/docs/user-guide/catalog/collections.mdx

@@ -0,0 +1,25 @@
+---
+title: "Collections"
+weight: 2
+---
+
+Collections allow you to group ProductVariants together by various criteria. A typical use of Collections is to create a hierarchical category tree which can be used in a navigation menu in your storefront.
+
+## Populating Collections
+
+Collections are _dynamic_, which means that you define a set of rules, and Vendure will automatically populate the Collection with ProductVariants according to those rules.
+
+The rules are defined by **filters**. A Collection can define multiple filters, for example:
+
+* Include all ProductVariants with a certain FacetValue
+* Include all ProductVariants whose name includes the word "sale"
+
+## Nesting Collections
+
+Collections can be nested inside one another, as many levels deep as needed.
+
+When populating a nested Collection, its own filters _plus the filters of all Collections above it_ are used to calculate the contents.
+
+## Public vs Private Collections
+
+A Collection can be made private, meaning that it will not be available in the storefront. This can be useful when you need to organize your inventory for internal purposes.

+ 32 - 0
docs/docs/user-guide/catalog/facets.mdx

@@ -0,0 +1,32 @@
+---
+title: "Facets"
+weight: 1
+---
+
+Facets are the primary means to attach structured data to your Products & ProductVariants. Typical uses of Facets include:
+
+* Enabling faceted search & filtering in the storefront
+* Organizing Products into Collections
+* Labelling Products for inclusion in Promotions 
+
+![./screen-facet-list.webp](./screen-facet-list.webp)
+
+A Facet has one or more FacetValues, for example:
+
+* Facet: "Brand"
+* Values: "Apple", "Logitech", "Sony", "LG" ...
+
+## Assigning to Products & Variants
+
+In the Product detail page, you can assign FacetValues by clicking the _ADD FACETS_ button toward the bottom of the Product or ProductVariant views.
+
+![./screen-facet-add.webp](./screen-facet-add.webp)
+
+## Public vs Private Facets
+
+The visibility of a Facet can be set to either _public_ or _private_.
+
+* **Public** facets are visible via the Shop API, meaning they can be listed in the storefront and used for faceted searches.
+* **Private** facets are only visible to Administrators, and cannot be used in storefront faceted searches. 
+
+Private facets can be useful for labelling Products for internal use. For example, you could create a "profit margin" Facet with "high" and "low" values. You wouldn't want to display these in the storefront, but you may want to use them e.g. in Promotion logic.

+ 33 - 0
docs/docs/user-guide/catalog/products.mdx

@@ -0,0 +1,33 @@
+---
+title: "Products"
+---
+
+Products represent the items you want to sell to your customers.
+
+## Products vs ProductVariants
+
+In Vendure, every Product has one or more _ProductVariants_. You can think of a Product as a "container" which houses the variants: 
+
+![./product-variants.png](./product-variants.png)
+
+In the diagram above you'll notice that it is the ProductVariants which have an SKU (_stock-keeping unit_: a unique product code) and a price.
+
+**Products** provide the overall name, description, slug, images. A product _does not_ have a price, sku, or stock level. 
+
+**ProductVariants** have a price, sku, stock level, tax settings. They are the actual things that get added to orders and purchased.
+
+## Tracking Inventory
+
+Vendure can track the stock levels of each of your ProductVariants. This is done by setting the "track inventory" option to "track" (or "inherit from global settings" if the [global setting](/user-guide/settings/global-settings) is set to track).
+
+![./screen-inventory.webp](./screen-inventory.webp)
+
+When tracking inventory:
+
+* When a customer checks out, the contents of the order will be "allocated". This means that the stock has not yet been sold, but it is no longer available to purchase (no longer _saleable_).
+* Once a Fulfillment has been created (see the Orders section), those allocated items will be converted into sales, meaning the stock level will be lowered by the corresponding quantity.
+* If a customer attempts to add more of a ProductVariant than are currently _saleable_, they will encounter an error.
+
+### Back orders
+
+Back orders can be enabled by setting a **negative value** as the "Out-of-stock threshold". This can be done via [global settings](/user-guide/settings/global-settings) or on a per-variant basis.

+ 28 - 0
docs/docs/user-guide/customers/index.mdx

@@ -0,0 +1,28 @@
+---
+title: "Customers"
+weight: 2
+---
+
+A Customer is anybody who has:
+
+* Placed an order
+* Registered an account
+
+The Customers section allows you to view and search for your customers. Clicking "edit" in the list view brings up the detail view, allowing you to edit the customer details and view orders and history.
+
+## Guest, Registered, Verified
+
+* **Guest:** Vendure allows "guest checkouts", which means people may place orders without needing to register an account with the storefront. 
+* **Registered:** When a customer registers for an account (using their email address by default), they are assigned this status.
+* **Verified:** A registered customer becomes verified once they have been able to confirm ownership of their email account. Note that if alternative authentication methods are set up on your store (e.g. Facebook login), then this workflow might be slightly different.
+
+## Customer Groups
+
+Customer Groups can be used for things like:
+
+* Grouping wholesale customers so that alternative tax calculations may be applied
+* Grouping members of a loyalty scheme for access to exclusive Promotions
+* Segmenting customers for other marketing purposes
+
+![./screen-customer-group.webp](./screen-customer-group.webp)
+

+ 9 - 0
docs/docs/user-guide/index.mdx

@@ -0,0 +1,9 @@
+---
+title: "Vendure User Guide"
+---
+
+This section is for store owners and staff who are charged with running a Vendure-based store.
+
+This guide assumes that your Vendure instance is running the AdminUiPlugin.
+
+We will roughly structure this guide to conform to the default layout of the Admin UI main navigation menu.

+ 40 - 0
docs/docs/user-guide/localization/index.mdx

@@ -0,0 +1,40 @@
+---
+title: "Localization"
+weight: 10
+---
+
+Vendure supports **customer-facing** (Shop API) localization by allowing you to define translations for the following objects:
+
+* Collections
+* Countries
+* Facets
+* FacetValue
+* Products
+* ProductOptions
+* ProductOptionGroups
+* ProductVariants
+* ShippingMethods  
+
+Vendure supports **admin-facing** (Admin API and Admin UI) localization by allowing you to define translations for labels and descriptions of the following objects:
+  
+* CustomFields
+* CollectionFilters
+* PaymentMethodHandlers
+* PromotionActions
+* PromotionConditions
+* ShippingCalculators
+* ShippingEligibilityCheckers
+
+## How to enable languages
+
+To select the set of languages you wish to create translations for, set them in the [global settings](/user-guide/settings/global-settings).
+
+Once more than one language is enabled, you will see a language switcher appear when editing the object types listed above.
+
+![../settings/screen-translations.webp](../settings/screen-translations.webp)
+
+## Setting the Admin UI language
+
+Separately, you can change the language used in the Admin UI from the menu in the top right. Note that changing the UI language has no effect on the localization of your products etc.
+
+![./screen-ui-language.webp](./screen-ui-language.webp)

+ 26 - 0
docs/docs/user-guide/orders/draft-orders.mdx

@@ -0,0 +1,26 @@
+---
+title: "Draft Orders"
+---
+
+Draft Orders are used when an Administrator would like to manually create an order via the Admin UI. For example, this can be useful when:
+
+- A customer phones up to place an order
+- A customer wants to place an order in person
+- You want to create an order on behalf of a customer, e.g. for a quote.
+- When testing Promotions
+
+To create a Draft Order, click the **"Create draft order"** button from the Order List view.
+
+From there you can:
+
+- Add ProductVariants to the Order using the search input marked "Add item to order"
+- Optionally activate coupon codes to trigger Promotions
+- Set the customer, shipping and billing addresses
+- Select the shipping method
+
+Once ready, click the **"Complete draft"** button to convert this Order from a Draft into a regular Order. At this stage the order can be paid for, and you can manually record the payment details.
+
+:::note
+Note: Draft Orders do not appear in a Customer's order history in the storefront (Shop API) while still
+in the "Draft" state.
+:::

+ 63 - 0
docs/docs/user-guide/orders/orders.mdx

@@ -0,0 +1,63 @@
+---
+title: "Orders"
+weight: 3
+---
+
+An Order is created whenever someone adds an item to their cart in the storefront. In Vendure, there is no distinction between a "cart" and an "order". Thus a "cart" is just an Order which has not yet passed through the checkout process.
+
+## The Order Workflow
+
+The exact set of stages that an Order goes through can be customized in Vendure to suit your particular business needs, but we can look at the default steps to get a good idea of the typical workflow:
+
+![./order-state-diagram-for-admin.png](./order-state-diagram-for-admin.png)
+
+When a new Order arrives, you would:
+
+1. **Settle the payment** if not already done (this may or may not be needed depending on the way your payment provider is configured).
+    ![./screen-settle-payment.webp](./screen-settle-payment.webp)
+1. **Create a Fulfillment** by clicking the "Fulfill Order" button in the top-right of the order detail page. A Fulfillment represents the physical package which will be sent to the customer. You may split up your order into multiple Fulfillments, if that makes sense from a logistical point of view.
+    ![./screen-fulfillment.webp](./screen-fulfillment.webp)
+1. **Mark the Fulfillment as "shipped"** once the physical package leaves your warehouse. 
+1. **Mark the Fulfillment as "delivered"** once you have notice of the package arriving with the customer. 
+   ![./screen-fulfillment-shipped.webp](./screen-fulfillment-shipped.webp)
+
+## Refunds
+
+You can refund one or more items from an Order by clicking this menu item, which is available once the payments are settled:
+
+![./screen-refund-button.webp](./screen-refund-button.webp)
+
+This will bring up a dialog which allows you to select which items to refund, as well as whether to refund shipping. You can also make an arbitrary adjustment to the refund amount if needed.
+
+A Refund is then made against the payment method used in that order. Some payment methods will handle refunds automatically, and others will expect you to perform the refund manually in your payment provider's admin interface, and then record the fact manually.
+
+## Cancellation
+
+One or more items may also be cancelled in a similar way to how refunds are handled. Performing a cancellation will return the selected items back into stock.
+
+Cancellations and refunds are often done together, but do not have to be. For example, you may refund a faulty item without requiring the customer to return it. This would be a pure refund.
+
+## Modifying an Order
+
+An Order can be modified after checkout is completed. 
+
+![./screen-modify-button.webp](./screen-modify-button.webp)
+
+Modification allows you to:
+
+* Alter the quantities of any items in the order
+* Remove items from the order  
+* Add new items to the order
+* Add arbitrary surcharges or discounts
+* Alter the shipping & billing address
+
+![./screen-modification.webp](./screen-modification.webp)
+
+Once you have made the desired modifications, you preview the changes including the price difference.
+
+If the modifications have resulted in an increased price (as in the above example), the Order will then be set into the "Arranging additional payment" state. This allows you to process another payment from the customer to make up the price difference.
+
+On the other hand, if the new price is less than what was originally paid (e.g. if the quantity is decreased), then a Refund will be generated against the payment method used.
+
+
+

+ 42 - 0
docs/docs/user-guide/promotions/index.mdx

@@ -0,0 +1,42 @@
+---
+title: "Promotions"
+weight: 4
+---
+
+Promotions are a means of offering discounts on an order based on various criteria. A Promotion consists of _conditions_ and _actions_.
+
+* **conditions** are the rules which determine whether the Promotion should be applied to the order.
+* **actions** specify exactly how this Promotion should modify the order.
+
+## Promotion Conditions
+
+A condition defines the criteria that must be met for the Promotion to be activated. Vendure comes with some simple conditions provided which enable things like:
+
+* If the order total is at least $X
+* Buy at least X of a certain product
+* Buy at least X of any product with the specified [FacetValues](/user-guide/catalog/facets)
+* If the customer is a member of the specified [Customer Group](/user-guide/customers#customer-groups)
+
+Vendure allows completely custom conditions to be defined by your developers, implementing the specific logic needed by your business.
+
+## Coupon codes
+
+A coupon code can be any text which will activate a Promotion. A coupon code can be used in conjunction with conditions if desired.
+
+
+:::note
+Note: Promotions **must** have either a **coupon code** _or_ **at least 1 condition** defined.
+:::
+
+## Promotion Actions
+
+If all the defined conditions pass (or if the specified coupon code is used), then the actions are performed on the order. Vendure comes with some commonly-used actions which allow promotions like:
+
+* Discount the whole order by a fixed amount
+* Discount the whole order by a percentage
+* Discount selected products by a percentage
+* Free shipping
+
+## Coupon code per-customer limit
+
+If a per-customer limit is specified, then the specified coupon code may only be used that many times by a single Customer. For guest checkouts, the "same customer" status is determined by the email address used when checking out.

+ 32 - 0
docs/docs/user-guide/settings/administrators-roles.mdx

@@ -0,0 +1,32 @@
+---
+title: "Administrators & Roles"
+---
+
+An **administrator** is a staff member who has access to the Admin UI, and is able to view and modify some or all of the items and settings.
+
+The exact permissions of _what_ a given administrator may view and modify is defined by which **roles** are assigned to that administrator.
+
+## Defining a Role
+
+The role detail page allows you to create a new role or edit an existing one. A role can be thought of as a list of permissions. Permissions are usually divided into four types:
+
+* **Create** allows you to create new items, e.g. "CreateProduct" will allow one to create a new product.
+* **Read** allows you to view items, but not modify or delete them.
+* **Update** allows you to make changes to existing items, but not to create new ones.
+* **Delete** allows you to delete items.
+
+Vendure comes with a few pre-defined roles for commonly-needed tasks, but you are free to modify these or create your own.
+
+In general, it is advisable to create roles with the fewest amount of privileges needed for the staff member to do their jobs. For example, a marketing manager would need to be able to create, read, update and delete promotions, but probably doesn't need to be able to update or delete payment methods.
+
+### The SuperAdmin role
+
+This is a special role which cannot be deleted or modified. This role grants _all permissions_ and is used to set up the store initially, and make certain special changes (such as creating new Channels).
+
+## Creating Administrators
+
+For each individual that needs to log in to the Admin UI, you should create a new administrator account. 
+
+Apart from filling in their details and selecting a strong password, you then need to assign at least one role. Roles "add together", meaning that when more than one role is assigned, the combined set of permissions of all assigned roles is granted to that administrator. 
+
+Thus, it is possible to create a set of specific roles "Inventory Manager", "Order Manager", "Customer Services", "Marketing Manager", and _compose_ them together as needed.

+ 16 - 0
docs/docs/user-guide/settings/channels.mdx

@@ -0,0 +1,16 @@
+---
+title: "Channels"
+---
+
+Channels allow you to split your store into multiple sub-stores, each of which can have its own selection of inventory, customers, orders, shipping methods etc.
+
+There are various reasons why you might want to do this:
+
+* Creating distinct stores for different countries, each with country-specific pricing, shipping and payment rules.
+* Implementing a multi-tenant application where many merchants have their own store, each confined to its own channel.
+* Implementing a marketplace where each seller has their own channel.
+
+Each channel defines some basic default settings - currency, language, tax options.
+
+There is _always_ a **default channel** - this is the "root" channel which contains _everything_, and any sub-channels can then contain a subset of the contents of the default channel.
+  

+ 10 - 0
docs/docs/user-guide/settings/countries-zones.mdx

@@ -0,0 +1,10 @@
+---
+title: "Countries & Zones"
+---
+
+**Countries** are where you define the list of countries which are relevant to your operations. This does not only include those countries you ship to, but also those countries which may appear on a billing address.
+
+By default, Vendure includes all countries in the list, but you are free to remove or disable any that you don't need.
+
+**Zones** provide a way to group countries. Zones are used mainly for defining [tax rates](/user-guide/settings/taxes) and can also be used in shipping calculations.
+

+ 10 - 0
docs/docs/user-guide/settings/global-settings.mdx

@@ -0,0 +1,10 @@
+---
+title: "Global Settings"
+---
+
+The global settings allow you to define certain configurations that affect _all_ channels.
+
+* **Available languages** defines which languages you wish to make available for translations. When more than one language has been enabled, you will see the language switcher appear when viewing translatable objects such as products, collections, facets and shipping methods.
+  ![./screen-translations.webp](./screen-translations.webp)
+* **Global out-of-stock threshold** sets the stock level at which a product variant is considered to be out of stock. Using a negative value enables backorder support. This setting can be overridden by individual product variants (see the [tracking inventory](/user-guide/catalog/products#tracking-inventory) guide).
+* **Track inventory by default** sets whether stock levels should be tracked. This setting can be overridden by individual product variants (see the [tracking inventory](/user-guide/catalog/products#tracking-inventory) guide).

+ 30 - 0
docs/docs/user-guide/settings/payment-methods.mdx

@@ -0,0 +1,30 @@
+---
+title: "Payment Methods"
+---
+
+Payment methods define how your storefront handles payments. Your storefront may offer multiple payment methods or just one.
+
+A Payment method consists of two parts: an **eligibility checker** and a **handler**
+
+## Payment eligibility checker
+
+This is an optional part which can be useful in certain situations where you want to limit a payment method based on things like:
+
+* Billing address
+* Order contents or total price
+* Customer group
+
+Since these requirements are particular to your business needs, Vendure does not provide any built-in checkers, but your developers can create one to suit your requirements.
+
+## Payment handler
+
+The payment handler contains the actual logic for processing a payment. Again, since there are many ways to handle payments, Vendure only provides a "dummy handler" by default and it is up to your developers to create integrations. 
+
+Payment handlers can be created which enable payment via:
+
+* Popular payment services such as Stripe, Paypal, Braintree, Klarna etc
+* Pay-on-delivery
+* Store credit
+* etc
+
+

+ 53 - 0
docs/docs/user-guide/settings/shipping-methods.mdx

@@ -0,0 +1,53 @@
+---
+title: "Shipping Methods"
+---
+
+Shipping methods define:
+
+* Whether an order is eligible for a particular shipping method
+* How much the shipping should cost for a given order
+* How the order will be fulfilled
+
+Let's take a closer look at each of these parts:
+
+## Shipping eligibility checker
+
+This is how we decide whether a particular shipping method may be applied to an order. This allows you to limit a particular shipping method based on things like:
+
+* Minimum order amount
+* Order weight
+* Shipping destination
+* Particular contents of the order
+* etc.
+
+By default, Vendure comes with a checker which can impose a minimum order amount. To implement more complex checks, your developers are able to create custom checkers to suit your requirements.
+
+## Shipping calculator
+
+The calculator is used to determine how much to charge for shipping an order. Calculators can be written to implement things like:
+
+* Determining shipping based on a 3rd-party service such as Shippo
+* Looking up prices from data supplied by couriers
+* Flat-rate shipping
+
+By default, Vendure comes with a simple flat-rate shipping calculator. Your developers can create more sophisticated integrations according to your business requirements.
+
+## Fulfillment handler
+
+By "fulfillment" we mean how we physically get the goods into the hands of the customer. Common fulfillment methods include:
+
+* Courier services such as FedEx, DPD, DHL, etc.
+* Collection by customer
+* Delivery via email for digital goods or licenses
+
+By default, Vendure comes with a "manual fulfillment handler", which allows you to manually enter the details of whatever actual method is used. For example, if you send the order by courier, you can enter the courier name and parcel number manually when creating an order.
+
+Your developers can however create much more sophisticated fulfillment handlers, which can enable things like automated calls to courier APIs, automated label generation, and so on.
+
+## Testing a Shipping Method
+
+At the bottom of the shipping method **detail page** you can test the current method by creating a fake order and shipping address and testing a) whether this method would be eligible, and b) how much it would cost.
+
+![./screen-shipping-test.webp](./screen-shipping-test.webp)
+
+Additionally, on the shipping method **list page** you can test _all_ shipping methods at once.

+ 30 - 0
docs/docs/user-guide/settings/taxes.mdx

@@ -0,0 +1,30 @@
+---
+title: "Taxes"
+---
+
+Taxes represent extra charges on top of the base price of a product. There are various forms of taxes that might be applicable, depending on local laws and the laws of the regions that your business serves. Common forms of applicable taxes are:
+
+* Value added tax (VAT)
+* Goods and services tax (GST)  
+* Sales taxes (as in the USA)
+
+
+## Tax Category
+
+In Vendure every product variant is assigned a **tax category**. In many countries, different rates of tax apply depending on the type of product being sold.
+
+For example, in the UK there are three rates of VAT:
+
+* Standard rate (20%)
+* Reduced rate (5%)
+* Zero rate (0%)
+
+Most types of products would fall into the "standard rate" category, but for instance books are classified as "zero rate".
+
+## Tax Rate
+
+Tax rates set the rate of tax for a given **tax category** destined for a particular **zone**. They are used by default in Vendure when calculating all taxes.
+
+## Tax Compliance
+
+Please note that tax compliance is a complex topic that varies significantly between countries. Vendure does not (and cannot) offer a complete out-of-the-box tax solution which is guaranteed to be compliant with your use-case. What we strive to do is to provide a very flexible set of tools that your developers can use to tailor tax calculations exactly to your needs. These are covered in the [Developer's guide to taxes](/guides/core-concepts/taxes/).