Explorar o código

Merge branch 'master' into minor

Michael Bromley hai 1 ano
pai
achega
5db4c6645d
Modificáronse 70 ficheiros con 717 adicións e 521 borrados
  1. 9 5
      docs/docs/guides/developer-guide/testing/index.md
  2. 4 3
      docs/docs/guides/extending-the-admin-ui/getting-started/index.md
  3. 1 1
      docs/docs/reference/core-plugins/email-plugin/email-event-handler-with-async-data.md
  4. 1 2
      docs/docs/reference/core-plugins/email-plugin/email-event-handler.md
  5. 19 19
      docs/docs/reference/core-plugins/email-plugin/email-plugin-options.md
  6. 43 5
      docs/docs/reference/core-plugins/email-plugin/email-plugin-types.md
  7. 5 5
      docs/docs/reference/core-plugins/email-plugin/index.md
  8. 2 2
      docs/docs/reference/core-plugins/email-plugin/template-loader.md
  9. 1 1
      docs/docs/reference/core-plugins/email-plugin/transport-options.md
  10. 1 1
      docs/docs/reference/typescript-api/assets/asset-options.md
  11. 2 3
      docs/docs/reference/typescript-api/auth/auth-options.md
  12. 4 4
      docs/docs/reference/typescript-api/auth/external-authentication-service.md
  13. 1 1
      docs/docs/reference/typescript-api/auth/superadmin-credentials.md
  14. 2 2
      docs/docs/reference/typescript-api/common/admin-ui/admin-ui-config.md
  15. 2 2
      docs/docs/reference/typescript-api/common/json-compatible.md
  16. 9 9
      docs/docs/reference/typescript-api/configurable-operation-def/default-form-config-hash.md
  17. 1 1
      docs/docs/reference/typescript-api/configuration/entity-options.md
  18. 1 1
      docs/docs/reference/typescript-api/configuration/runtime-vendure-config.md
  19. 1 1
      docs/docs/reference/typescript-api/configuration/system-options.md
  20. 1 1
      docs/docs/reference/typescript-api/configuration/vendure-config.md
  21. 1 1
      docs/docs/reference/typescript-api/custom-fields/custom-field-type.md
  22. 1 1
      docs/docs/reference/typescript-api/import-export/import-export-options.md
  23. 1 1
      docs/docs/reference/typescript-api/job-queue/job-queue-options.md
  24. 1 1
      docs/docs/reference/typescript-api/orders/order-options.md
  25. 1 1
      docs/docs/reference/typescript-api/payment/payment-options.md
  26. 1 1
      docs/docs/reference/typescript-api/plugin/vendure-plugin-metadata.md
  27. 1 1
      docs/docs/reference/typescript-api/products-stock/catalog-options.md
  28. 1 1
      docs/docs/reference/typescript-api/promotions/promotion-options.md
  29. 3 3
      docs/docs/reference/typescript-api/services/collection-service.md
  30. 1 1
      docs/docs/reference/typescript-api/shipping/shipping-options.md
  31. 1 1
      docs/docs/reference/typescript-api/tax/tax-options.md
  32. 0 4
      docs/docs/user-guide/catalog/index.md
  33. 2 3
      docs/docs/user-guide/catalog/products.md
  34. 1 6
      docs/docs/user-guide/index.md
  35. 1 1
      docs/docs/user-guide/localization/index.md
  36. 2 6
      docs/docs/user-guide/orders/draft-orders.md
  37. 4 4
      docs/docs/user-guide/promotions/index.md
  38. 1 1
      docs/docs/user-guide/settings/countries-zones.md
  39. 2 2
      docs/docs/user-guide/settings/global-settings.md
  40. 1 1
      docs/docs/user-guide/settings/taxes.md
  41. 7 3
      docs/docusaurus.config.js
  42. 32 0
      docs/sidebars.js
  43. 64 0
      license/signatures/version1/cla.json
  44. 0 26
      package-lock.json
  45. 4 5
      packages/admin-ui/src/lib/catalog/src/components/create-product-variant-dialog/create-product-variant-dialog.component.ts
  46. 1 1
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.scss
  47. 2 2
      packages/admin-ui/src/lib/core/src/shared/pipes/locale-date.pipe.spec.ts
  48. 0 3
      packages/admin-ui/src/lib/core/src/shared/pipes/locale-date.pipe.ts
  49. 8 8
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  50. 160 160
      packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json
  51. 7 7
      packages/common/src/shared-types.ts
  52. 1 0
      packages/core/src/api/resolvers/shop/shop-order.resolver.ts
  53. 2 3
      packages/core/src/config/vendure-config.ts
  54. 6 1
      packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts
  55. 4 4
      packages/core/src/service/helpers/external-authentication/external-authentication.service.ts
  56. 142 93
      packages/core/src/service/services/collection.service.ts
  57. 1 0
      packages/core/src/service/services/stock-movement.service.ts
  58. 0 2
      packages/create/package.json
  59. 20 7
      packages/create/src/create-vendure-app.ts
  60. 1 1
      packages/create/src/gather-user-responses.ts
  61. 3 3
      packages/create/src/helpers.ts
  62. 1 0
      packages/create/templates/.env.hbs
  63. 1 0
      packages/create/templates/environment.d.hbs
  64. 6 5
      packages/create/templates/vendure-config.hbs
  65. 2 2
      packages/dev-server/dev-config.ts
  66. 0 1
      packages/email-plugin/src/handler/event-handler.ts
  67. 77 68
      packages/email-plugin/src/plugin.spec.ts
  68. 5 5
      packages/email-plugin/src/plugin.ts
  69. 22 1
      packages/email-plugin/src/types.ts
  70. 1 0
      packages/payments-plugin/src/mollie/mollie.service.ts

+ 9 - 5
docs/docs/guides/developer-guide/testing/index.md

@@ -26,18 +26,22 @@ For a working example of a Vendure plugin with e2e testing, see the [real-world-
   - `@swc/core`
   - `unplugin-swc`
 
+```sh
+npm install --save-dev @vendure/testing vitest graphql-tag @swc/core unplugin-swc
+```
+
 ### Configure Vitest
 
-Create a `vitest.config.js` file in the root of your project:
+Create a `vitest.config.mts` file in the root of your project:
 
-```ts
+```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',
+        include: ['**/*.e2e-spec.ts'],
         typecheck: {
             tsconfig: path.join(__dirname, 'tsconfig.e2e.json'),
         },
@@ -60,9 +64,9 @@ export default defineConfig({
 
 and a `tsconfig.e2e.json` tsconfig file for the tests:
 
-```json
+```json title="tsconfig.e2e.json"
 {
-  "extends": "../tsconfig.json",
+  "extends": "./tsconfig.json",
   "compilerOptions": {
     "types": ["node"],
     "lib": ["es2015"],

+ 4 - 3
docs/docs/guides/extending-the-admin-ui/getting-started/index.md

@@ -357,12 +357,13 @@ export const config: VendureConfig = {
 ```
 
 :::info
-To compile the angular app ahead of time (for production) and copy the dist folder to Vendure's output dist folder, include the following commands in your packages.json scripts:
+To compile the angular app ahead of time (for production) and copy the dist folder to
+Vendure's output dist folder, include the following commands in your package.json scripts:
 
 ```json
 {
     "scripts": {
-        "copy": "npx copyfiles -u 1 'src/__admin-ui/dist/**/*' dist",
+        "copy": "npx copyfiles -u 1 'admin-ui/dist/**/*' dist",
         "build": "tsc && yarn copy",
         "build:admin": "rimraf admin-ui && npx ts-node src/compile-admin-ui.ts"
     }
@@ -370,7 +371,7 @@ To compile the angular app ahead of time (for production) and copy the dist fold
 ```
 
 "build:admin" will remove the admin-ui folder and run the compileUiExtensions function to generate the admin-ui Angular app.
-Make sure to install copyfiles before running the "copy" command:
+Make sure to install `copyfiles` before running the "copy" command:
 
 <Tabs>
 <TabItem value="npm" label="npm" default>

+ 1 - 1
docs/docs/reference/core-plugins/email-plugin/email-event-handler-with-async-data.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## EmailEventHandlerWithAsyncData
 
-<GenerationInfo sourceFile="packages/email-plugin/src/handler/event-handler.ts" sourceLine="456" packageName="@vendure/email-plugin" />
+<GenerationInfo sourceFile="packages/email-plugin/src/handler/event-handler.ts" sourceLine="455" packageName="@vendure/email-plugin" />
 
 Identical to the <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a> but with a `data` property added to the `event` based on the result
 of the `.loadData()` function.

+ 1 - 2
docs/docs/reference/core-plugins/email-plugin/email-event-handler.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## EmailEventHandler
 
-<GenerationInfo sourceFile="packages/email-plugin/src/handler/event-handler.ts" sourceLine="136" packageName="@vendure/email-plugin" />
+<GenerationInfo sourceFile="packages/email-plugin/src/handler/event-handler.ts" sourceLine="135" packageName="@vendure/email-plugin" />
 
 The EmailEventHandler defines how the EmailPlugin will respond to a given event.
 
@@ -119,7 +119,6 @@ const config: VendureConfig = {
   plugins: [
     EmailPlugin.init({
       handler: [...defaultEmailHandlers, quoteRequestedHandler],
-      templatePath: path.join(__dirname, 'vendure/email/templates'),
       // ... etc
     }),
   ],

+ 19 - 19
docs/docs/reference/core-plugins/email-plugin/email-plugin-options.md

@@ -19,11 +19,11 @@ Configuration for the EmailPlugin.
 interface EmailPluginOptions {
     templatePath?: string;
     templateLoader?: TemplateLoader;
-    transport:
-        | EmailTransportOptions
-        | ((
-              injector?: Injector,
-              ctx?: RequestContext,
+    transport:
+        | EmailTransportOptions
+        | ((
+              injector?: Injector,
+              ctx?: RequestContext,
           ) => EmailTransportOptions | Promise<EmailTransportOptions>);
     handlers: Array<EmailEventHandler<string, any>>;
     globalTemplateVars?: { [key: string]: any } | GlobalTemplateVarsFn;
@@ -38,44 +38,44 @@ interface EmailPluginOptions {
 
 <MemberInfo kind="property" type={`string`}   />
 
-The path to the location of the email templates. In a default Vendure installation,
+The path to the location of the email templates. In a default Vendure installation,
 the templates are installed to `<project root>/vendure/email/templates`.
 ### templateLoader
 
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/email-plugin/template-loader#templateloader'>TemplateLoader</a>`}  since="2.0.0"  />
 
-An optional TemplateLoader which can be used to load templates from a custom location or async service.
+An optional TemplateLoader which can be used to load templates from a custom location or async service.
 The default uses the FileBasedTemplateLoader which loads templates from `<project root>/vendure/email/templates`
 ### transport
 
-<MemberInfo kind="property" type={`| <a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a>         | ((               injector?: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>,               ctx?: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>,           ) =&#62; <a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a> | Promise&#60;<a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a>&#62;)`}   />
+<MemberInfo kind="property" type={`| <a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a>
         | ((
               injector?: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>,
               ctx?: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>,
           ) =&#62; <a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a> | Promise&#60;<a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a>&#62;)`}   />
 
 Configures how the emails are sent.
 ### handlers
 
 <MemberInfo kind="property" type={`Array&#60;<a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;string, any&#62;&#62;`}   />
 
-An array of <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>s which define which Vendure events will trigger
+An array of <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>s which define which Vendure events will trigger
 emails, and how those emails are generated.
 ### globalTemplateVars
 
 <MemberInfo kind="property" type={`{ [key: string]: any } | <a href='/reference/core-plugins/email-plugin/email-plugin-options#globaltemplatevarsfn'>GlobalTemplateVarsFn</a>`}   />
 
-An object containing variables which are made available to all templates. For example,
-the storefront URL could be defined here and then used in the "email address verification"
-email. Use the GlobalTemplateVarsFn if you need to retrieve variables from Vendure or
+An object containing variables which are made available to all templates. For example,
+the storefront URL could be defined here and then used in the "email address verification"
+email. Use the GlobalTemplateVarsFn if you need to retrieve variables from Vendure or
 plugin services.
 ### emailSender
 
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/email-plugin/email-sender#emailsender'>EmailSender</a>`} default={`<a href='/reference/core-plugins/email-plugin/email-sender#nodemaileremailsender'>NodemailerEmailSender</a>`}   />
 
-An optional allowed EmailSender, used to allow custom implementations of the send functionality
+An optional allowed EmailSender, used to allow custom implementations of the send functionality
 while still utilizing the existing emailPlugin functionality.
 ### emailGenerator
 
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/email-plugin/email-generator#emailgenerator'>EmailGenerator</a>`} default={`<a href='/reference/core-plugins/email-plugin/email-generator#handlebarsmjmlgenerator'>HandlebarsMjmlGenerator</a>`}   />
 
-An optional allowed EmailGenerator, used to allow custom email generation functionality to
+An optional allowed EmailGenerator, used to allow custom email generation functionality to
 better match with custom email sending functionality.
 
 
@@ -86,8 +86,8 @@ better match with custom email sending functionality.
 
 <GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="64" packageName="@vendure/email-plugin" since="2.3.0" />
 
-Allows you to dynamically load the "globalTemplateVars" key async and access Vendure services
-to create the object. This is not a requirement. You can also specify a simple static object if your
+Allows you to dynamically load the "globalTemplateVars" key async and access Vendure services
+to create the object. This is not a requirement. You can also specify a simple static object if your
 projects doesn't need to access async or dynamic values.
 
 *Example*
@@ -112,9 +112,9 @@ EmailPlugin.init({
 ```
 
 ```ts title="Signature"
-type GlobalTemplateVarsFn = (
-    ctx: RequestContext,
-    injector: Injector,
+type GlobalTemplateVarsFn = (
+    ctx: RequestContext,
+    injector: Injector,
 ) => Promise<{ [key: string]: any }>
 ```
 

+ 43 - 5
docs/docs/reference/core-plugins/email-plugin/email-plugin-types.md

@@ -128,9 +128,47 @@ type EmailAttachment = Omit<Attachment, 'raw'> & { path?: string }
 ```
 
 
+## LoadTemplateInput
+
+<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="401" packageName="@vendure/email-plugin" />
+
+The object passed to the <a href='/reference/core-plugins/email-plugin/template-loader#templateloader'>TemplateLoader</a> `loadTemplate()` method.
+
+```ts title="Signature"
+interface LoadTemplateInput {
+    type: string;
+    templateName: string;
+    templateVars: any;
+}
+```
+
+<div className="members-wrapper">
+
+### type
+
+<MemberInfo kind="property" type={`string`}   />
+
+The type corresponds to the string passed to the EmailEventListener constructor.
+### templateName
+
+<MemberInfo kind="property" type={`string`}   />
+
+The template name is specified by the EmailEventHander's call to
+the `addTemplate()` method, and will default to `body.hbs`
+### templateVars
+
+<MemberInfo kind="property" type={`any`}   />
+
+The variables defined by the globalTemplateVars as well as any variables defined in the
+EmailEventHandler's `setTemplateVars()` method.
+
+
+</div>
+
+
 ## SetTemplateVarsFn
 
-<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="413" packageName="@vendure/email-plugin" />
+<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="434" packageName="@vendure/email-plugin" />
 
 A function used to define template variables available to email templates.
 See <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>.setTemplateVars().
@@ -145,7 +183,7 @@ type SetTemplateVarsFn<Event> = (
 
 ## SetAttachmentsFn
 
-<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="427" packageName="@vendure/email-plugin" />
+<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="448" packageName="@vendure/email-plugin" />
 
 A function used to define attachments to be sent with the email.
 See https://nodemailer.com/message/attachments/ for more information about
@@ -158,7 +196,7 @@ type SetAttachmentsFn<Event> = (event: Event) => EmailAttachment[] | Promise<Ema
 
 ## SetSubjectFn
 
-<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="435" packageName="@vendure/email-plugin" />
+<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="456" packageName="@vendure/email-plugin" />
 
 A function used to define the subject to be sent with the email.
 
@@ -173,7 +211,7 @@ type SetSubjectFn<Event> = (
 
 ## OptionalAddressFields
 
-<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="449" packageName="@vendure/email-plugin" since="1.1.0" />
+<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="470" packageName="@vendure/email-plugin" since="1.1.0" />
 
 Optional address-related fields for sending the email.
 
@@ -209,7 +247,7 @@ An email address that will appear on the _Reply-To:_ field
 
 ## SetOptionalAddressFieldsFn
 
-<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="475" packageName="@vendure/email-plugin" since="1.1.0" />
+<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="496" packageName="@vendure/email-plugin" since="1.1.0" />
 
 A function used to set the <a href='/reference/core-plugins/email-plugin/email-plugin-types#optionaladdressfields'>OptionalAddressFields</a>.
 

+ 5 - 5
docs/docs/reference/core-plugins/email-plugin/index.md

@@ -39,14 +39,14 @@ or
 *Example*
 
 ```ts
-import { defaultEmailHandlers, EmailPlugin } from '@vendure/email-plugin';
+import { defaultEmailHandlers, EmailPlugin, FileBasedTemplateLoader } from '@vendure/email-plugin';
 
 const config: VendureConfig = {
   // Add an instance of the plugin to the plugins array
   plugins: [
     EmailPlugin.init({
       handler: defaultEmailHandlers,
-      templatePath: path.join(__dirname, 'static/email/templates'),
+      templateLoader: new FileBasedTemplateLoader(path.join(__dirname, '../static/email/templates')),
       transport: {
         type: 'smtp',
         host: 'smtp.example.com',
@@ -207,13 +207,13 @@ channel aware transport settings.
 *Example*
 
 ```ts
-import { defaultEmailHandlers, EmailPlugin } from '@vendure/email-plugin';
+import { defaultEmailHandlers, EmailPlugin, FileBasedTemplateLoader } from '@vendure/email-plugin';
 import { MyTransportService } from './transport.services.ts';
 const config: VendureConfig = {
   plugins: [
     EmailPlugin.init({
       handler: defaultEmailHandlers,
-      templatePath: path.join(__dirname, 'static/email/templates'),
+      templateLoader: new FileBasedTemplateLoader(path.join(__dirname, '../static/email/templates')),
       transport: (injector, ctx) => {
         if (ctx) {
           return injector.get(MyTransportService).getSettings(ctx);
@@ -241,7 +241,7 @@ EmailPlugin.init({
   devMode: true,
   route: 'mailbox',
   handler: defaultEmailHandlers,
-  templatePath: path.join(__dirname, 'vendure/email/templates'),
+  templateLoader: new FileBasedTemplateLoader(path.join(__dirname, '../static/email/templates')),
   outputPath: path.join(__dirname, 'test-emails'),
 })
 ```

+ 2 - 2
docs/docs/reference/core-plugins/email-plugin/template-loader.md

@@ -46,7 +46,7 @@ interface TemplateLoader {
 
 ### loadTemplate
 
-<MemberInfo kind="method" type={`(injector: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>, ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, input: LoadTemplateInput) => Promise&#60;string&#62;`}   />
+<MemberInfo kind="method" type={`(injector: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>, ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, input: <a href='/reference/core-plugins/email-plugin/email-plugin-types#loadtemplateinput'>LoadTemplateInput</a>) => Promise&#60;string&#62;`}   />
 
 Load template and return it's content as a string
 ### loadPartials
@@ -87,7 +87,7 @@ class FileBasedTemplateLoader implements TemplateLoader {
 
 ### loadTemplate
 
-<MemberInfo kind="method" type={`(_injector: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>, _ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, { type, templateName }: LoadTemplateInput) => Promise&#60;string&#62;`}   />
+<MemberInfo kind="method" type={`(_injector: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>, _ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, { type, templateName }: <a href='/reference/core-plugins/email-plugin/email-plugin-types#loadtemplateinput'>LoadTemplateInput</a>) => Promise&#60;string&#62;`}   />
 
 
 ### loadPartials

+ 1 - 1
docs/docs/reference/core-plugins/email-plugin/transport-options.md

@@ -86,7 +86,7 @@ See [Nodemailers's SES docs](https://nodemailer.com/transports/ses/) for more de
   plugins: [
     EmailPlugin.init({
       handler: defaultEmailHandlers,
-      templatePath: path.join(__dirname, 'static/email/templates'),
+      templateLoader: new FileBasedTemplateLoader(path.join(__dirname, '../static/email/templates')),
       transport: {
         type: 'ses',
         SES: { ses, aws: { SendRawEmailCommand } },

+ 1 - 1
docs/docs/reference/typescript-api/assets/asset-options.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## AssetOptions
 
-<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="628" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="627" packageName="@vendure/core" />
 
 The AssetOptions define how assets (images and other files) are named and stored, and how preview images are generated.
 

+ 2 - 3
docs/docs/reference/typescript-api/auth/auth-options.md

@@ -100,9 +100,8 @@ taken from the database.
 
 Determines whether new User accounts require verification of their email address.
 
-If set to "true", when registering via the `registerCustomerAccount` mutation, one should *not* set the
-`password` property - doing so will result in an error. Instead, the password is set at a later stage
-(once the email with the verification token has been opened) via the `verifyCustomerAccount` mutation.
+If set to "true", the customer will be required to verify their email address using a verification token
+they receive in their email. See the `registerCustomerAccount` mutation for more details on the verification behavior.
 ### verificationTokenDuration
 
 <MemberInfo kind="property" type={`string | number`} default={`'7d'`}   />

+ 4 - 4
docs/docs/reference/typescript-api/auth/external-authentication-service.md

@@ -24,10 +24,10 @@ class ExternalAuthenticationService {
     createCustomerAndUser(ctx: RequestContext, config: {
             strategy: string;
             externalIdentifier: string;
-            verified: boolean;
             emailAddress: string;
-            firstName?: string;
-            lastName?: string;
+            firstName: string;
+            lastName: string;
+            verified?: boolean;
         }) => Promise<User>;
     createAdministratorAndUser(ctx: RequestContext, config: {
             strategy: string;
@@ -67,7 +67,7 @@ Looks up a User based on their identifier from an external authentication
 provider, ensuring this User is associated with an Administrator account.
 ### createCustomerAndUser
 
-<MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, config: {
             strategy: string;
             externalIdentifier: string;
             verified: boolean;
             emailAddress: string;
             firstName?: string;
             lastName?: string;
         }) => Promise&#60;<a href='/reference/typescript-api/entities/user#user'>User</a>&#62;`}   />
+<MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, config: {
             strategy: string;
             externalIdentifier: string;
             emailAddress: string;
             firstName: string;
             lastName: string;
             verified?: boolean;
         }) => Promise&#60;<a href='/reference/typescript-api/entities/user#user'>User</a>&#62;`}   />
 
 If a customer has been successfully authenticated by an external authentication provider, yet cannot
 be found using `findCustomerUser`, then we need to create a new User and

+ 1 - 1
docs/docs/reference/typescript-api/auth/superadmin-credentials.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## SuperadminCredentials
 
-<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="804" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="803" packageName="@vendure/core" />
 
 These credentials will be used to create the Superadmin user & administrator
 when Vendure first bootstraps.

+ 2 - 2
docs/docs/reference/typescript-api/common/admin-ui/admin-ui-config.md

@@ -42,14 +42,14 @@ interface AdminUiConfig {
 
 ### apiHost
 
-<MemberInfo kind="property" type={`string | 'auto'`} default={`'http://localhost'`}   />
+<MemberInfo kind="property" type={`string | 'auto'`} default={`'auto'`}   />
 
 The hostname of the Vendure server which the admin UI will be making API calls
 to. If set to "auto", the Admin UI app will determine the hostname from the
 current location (i.e. `window.location.hostname`).
 ### apiPort
 
-<MemberInfo kind="property" type={`number | 'auto'`} default={`3000`}   />
+<MemberInfo kind="property" type={`number | 'auto'`} default={`'auto'`}   />
 
 The port of the Vendure server which the admin UI will be making API calls
 to. If set to "auto", the Admin UI app will determine the port from the

+ 2 - 2
docs/docs/reference/typescript-api/common/json-compatible.md

@@ -21,7 +21,7 @@ type JsonCompatible<T> = {
     [P in keyof T]: T[P] extends Json
         ? T[P]
         : Pick<T, P> extends Required<Pick<T, P>>
-        ? never
-        : JsonCompatible<T[P]>;
+          ? never
+          : JsonCompatible<T[P]>;
 }
 ```

+ 9 - 9
docs/docs/reference/typescript-api/configurable-operation-def/default-form-config-hash.md

@@ -29,15 +29,15 @@ type DefaultFormConfigHash = {
     'product-selector-form-input': Record<string, never>;
     'relation-form-input': Record<string, never>;
     'rich-text-form-input': Record<string, never>;
-    'select-form-input': {
-        options?: Array<{ value: string; label?: Array<Omit<LocalizedString, '__typename'>> }>;
+    'select-form-input': {
+        options?: Array<{ value: string; label?: Array<Omit<LocalizedString, '__typename'>> }>;
     };
     'text-form-input': { prefix?: string; suffix?: string };
-    'textarea-form-input': {
-        spellcheck?: boolean;
+    'textarea-form-input': {
+        spellcheck?: boolean;
     };
-    'product-multi-form-input': {
-        selectionMode?: 'product' | 'variant';
+    'product-multi-form-input': {
+        selectionMode?: 'product' | 'variant';
     };
     'combination-mode-form-input': Record<string, never>;
 }
@@ -107,7 +107,7 @@ type DefaultFormConfigHash = {
 
 ### 'select-form-input'
 
-<MemberInfo kind="property" type={`{
         options?: Array&#60;{ value: string; label?: Array&#60;Omit&#60;LocalizedString, '__typename'&#62;&#62; }&#62;;
     }`}   />
+<MemberInfo kind="property" type={`{         options?: Array&#60;{ value: string; label?: Array&#60;Omit&#60;LocalizedString, '__typename'&#62;&#62; }&#62;;     }`}   />
 
 
 ### 'text-form-input'
@@ -117,12 +117,12 @@ type DefaultFormConfigHash = {
 
 ### 'textarea-form-input'
 
-<MemberInfo kind="property" type={`{
         spellcheck?: boolean;
     }`}   />
+<MemberInfo kind="property" type={`{         spellcheck?: boolean;     }`}   />
 
 
 ### 'product-multi-form-input'
 
-<MemberInfo kind="property" type={`{
         selectionMode?: 'product' | 'variant';
     }`}   />
+<MemberInfo kind="property" type={`{         selectionMode?: 'product' | 'variant';     }`}   />
 
 
 ### 'combination-mode-form-input'

+ 1 - 1
docs/docs/reference/typescript-api/configuration/entity-options.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## EntityOptions
 
-<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="954" packageName="@vendure/core" since="1.3.0" />
+<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="953" packageName="@vendure/core" since="1.3.0" />
 
 Options relating to the internal handling of entities.
 

+ 1 - 1
docs/docs/reference/typescript-api/configuration/runtime-vendure-config.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## RuntimeVendureConfig
 
-<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="1201" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="1200" packageName="@vendure/core" />
 
 This interface represents the VendureConfig object available at run-time, i.e. the user-supplied
 config values have been merged with the <a href='/reference/typescript-api/configuration/default-config#defaultconfig'>defaultConfig</a> values.

+ 1 - 1
docs/docs/reference/typescript-api/configuration/system-options.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## SystemOptions
 
-<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="1043" packageName="@vendure/core" since="1.6.0" />
+<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="1042" packageName="@vendure/core" since="1.6.0" />
 
 Options relating to system functions.
 

+ 1 - 1
docs/docs/reference/typescript-api/configuration/vendure-config.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## VendureConfig
 
-<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="1071" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="1070" packageName="@vendure/core" />
 
 All possible configuration options are defined by the
 [`VendureConfig`](https://github.com/vendure-ecommerce/vendure/blob/master/server/src/config/vendure-config.ts) interface.

+ 1 - 1
docs/docs/reference/typescript-api/custom-fields/custom-field-type.md

@@ -28,7 +28,7 @@ boolean      | tinyint (m), bool (p), boolean (s)    | Boolean
 datetime     | datetime (m,s), timestamp (p)         | DateTime
 relation     | many-to-one / many-to-many relation   | As specified in config
 
-Additionally, the CustomFieldType also dictates which [configuration options](/reference/typescript-api/custom-fields/#configuration-options)
+Additionally, the CustomFieldType also dictates which [configuration options](/reference/typescript-api/custom-fields/#custom-field-config-properties)
 are available for that custom field.
 
 ```ts title="Signature"

+ 1 - 1
docs/docs/reference/typescript-api/import-export/import-export-options.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## ImportExportOptions
 
-<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="889" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="888" packageName="@vendure/core" />
 
 Options related to importing & exporting data.
 

+ 1 - 1
docs/docs/reference/typescript-api/job-queue/job-queue-options.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## JobQueueOptions
 
-<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="913" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="912" packageName="@vendure/core" />
 
 Options related to the built-in job queue.
 

+ 1 - 1
docs/docs/reference/typescript-api/orders/order-options.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## OrderOptions
 
-<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="483" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="482" packageName="@vendure/core" />
 
 
 

+ 1 - 1
docs/docs/reference/typescript-api/payment/payment-options.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## PaymentOptions
 
-<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="826" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="825" packageName="@vendure/core" />
 
 Defines payment-related options in the <a href='/reference/typescript-api/configuration/vendure-config#vendureconfig'>VendureConfig</a>.
 

+ 1 - 1
docs/docs/reference/typescript-api/plugin/vendure-plugin-metadata.md

@@ -71,7 +71,7 @@ To effectively disable this check for a plugin, you can use an overly-permissive
 *Example*
 
 ```ts
-compatibility: '^2.0.0'
+compatibility: '^3.0.0'
 ```
 
 

+ 1 - 1
docs/docs/reference/typescript-api/products-stock/catalog-options.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## CatalogOptions
 
-<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="675" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="674" packageName="@vendure/core" />
 
 Options related to products and collections.
 

+ 1 - 1
docs/docs/reference/typescript-api/promotions/promotion-options.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## PromotionOptions
 
-<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="737" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="736" packageName="@vendure/core" />
 
 
 

+ 3 - 3
docs/docs/reference/typescript-api/services/collection-service.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## CollectionService
 
-<GenerationInfo sourceFile="packages/core/src/service/services/collection.service.ts" sourceLine="67" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/service/services/collection.service.ts" sourceLine="72" packageName="@vendure/core" />
 
 Contains methods relating to <a href='/reference/typescript-api/entities/collection#collection'>Collection</a> entities.
 
@@ -25,7 +25,7 @@ class CollectionService implements OnModuleInit {
     getAvailableFilters(ctx: RequestContext) => ConfigurableOperationDefinition[];
     getParent(ctx: RequestContext, collectionId: ID) => Promise<Collection | undefined>;
     getChildren(ctx: RequestContext, collectionId: ID) => Promise<Collection[]>;
-    getBreadcrumbs(ctx: RequestContext, collection: Collection) => Promise<Array<{ name: string; id: ID }>>;
+    getBreadcrumbs(ctx: RequestContext, collection: Collection) => Promise<Array<{ name: string; id: ID, slug: string }>>;
     getCollectionsByProductId(ctx: RequestContext, productId: ID, publicOnly: boolean) => Promise<Array<Translated<Collection>>>;
     getDescendants(ctx: RequestContext, rootId: ID, maxDepth: number = Number.MAX_SAFE_INTEGER) => Promise<Array<Translated<Collection>>>;
     getAncestors(collectionId: ID) => Promise<Collection[]>;
@@ -89,7 +89,7 @@ Returns all configured CollectionFilters, as specified by the <a href='/referenc
 Returns all child Collections of the Collection with the given id.
 ### getBreadcrumbs
 
-<MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, collection: <a href='/reference/typescript-api/entities/collection#collection'>Collection</a>) => Promise&#60;Array&#60;{ name: string; id: <a href='/reference/typescript-api/common/id#id'>ID</a> }&#62;&#62;`}   />
+<MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, collection: <a href='/reference/typescript-api/entities/collection#collection'>Collection</a>) => Promise&#60;Array&#60;{ name: string; id: <a href='/reference/typescript-api/common/id#id'>ID</a>, slug: string }&#62;&#62;`}   />
 
 Returns an array of name/id pairs representing all ancestor Collections up
 to the Root Collection.

+ 1 - 1
docs/docs/reference/typescript-api/shipping/shipping-options.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## ShippingOptions
 
-<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="753" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="752" packageName="@vendure/core" />
 
 
 

+ 1 - 1
docs/docs/reference/typescript-api/tax/tax-options.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## TaxOptions
 
-<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="866" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/config/vendure-config.ts" sourceLine="865" packageName="@vendure/core" />
 
 
 

+ 0 - 4
docs/docs/user-guide/catalog/index.md

@@ -1,4 +0,0 @@
----
-title: "Catalog"
-weight: 1
----

+ 2 - 3
docs/docs/user-guide/catalog/products.md

@@ -1,6 +1,5 @@
 ---
 title: "Products"
-weight: 0
 ---
 
 # Products
@@ -21,7 +20,7 @@ In the diagram above you'll notice that it is the ProductVariants which have an
 
 ## 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]({{< relref "/user-guide/settings/global-settings" >}}) is set to track).
+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)
 
@@ -33,4 +32,4 @@ When tracking inventory:
 
 ### 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]({{< relref "/user-guide/settings/global-settings" >}}) or on a per-variant basis.
+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.

+ 1 - 6
docs/docs/user-guide/index.md

@@ -1,9 +1,4 @@
----
-title: "Administrator Guide"
-weight: 1
----
- 
-# Administrator Guide
+# Vendure User Guide
 
 This section is for store owners and staff who are charged with running a Vendure-based store.
 

+ 1 - 1
docs/docs/user-guide/localization/index.md

@@ -29,7 +29,7 @@ Vendure supports **admin-facing** (Admin API and Admin UI) localization by allow
 
 ## How to enable languages
 
-To select the set of languages you wish to create translations for, set them in the [global settings]({{< relref "/user-guide/settings/global-settings" >}}).
+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.
 

+ 2 - 6
docs/docs/user-guide/orders/draft-orders.md

@@ -4,10 +4,6 @@ title: "Draft Orders"
 
 # Draft Orders
 
-{{% alert "warning" %}}
-Note: Draft Orders are available from Vendure v1.8+
-{{% /alert %}}
-
 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
@@ -26,7 +22,7 @@ From there you can:
 
 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.
 
-{{% alert "primary" %}}
+:::note
 Note: Draft Orders do not appear in a Customer's order history in the storefront (Shop API) while still
 in the "Draft" state.
-{{% /alert %}}
+:::

+ 4 - 4
docs/docs/user-guide/promotions/index.md

@@ -16,8 +16,8 @@ A condition defines the criteria that must be met for the Promotion to be activa
 
 * If the order total is at least $X
 * Buy at least X of a certain product
-* But at least X of any product with the specified [FacetValues]({{< relref "/user-guide/catalog/facets" >}})
-* If the customer is a member of the specified [Customer Group]({{< relref "/user-guide/customers" >}}#customer-groups)
+* But 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.
 
@@ -26,9 +26,9 @@ Vendure allows completely custom conditions to be defined by your developers, im
 A coupon code can be any text which will activate a Promotion. A coupon code can be used in conjunction with conditions if desired.
 
 
-{{< alert "primary" >}}
+:::note
 Note: Promotions **must** have either a **coupon code** _or_ **at least 1 condition** defined.
-{{< /alert >}}
+:::
 
 ## Promotion Actions
 

+ 1 - 1
docs/docs/user-guide/settings/countries-zones.md

@@ -8,5 +8,5 @@ title: "Countries & Zones"
 
 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]({{< relref "/user-guide/settings/taxes" >}}) and can also be used in shipping calculations.
+**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.
 

+ 2 - 2
docs/docs/user-guide/settings/global-settings.md

@@ -8,5 +8,5 @@ The global settings allow you to define certain configurations that affect _all_
 
 * **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]({{< relref "/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]({{< relref "/user-guide/catalog/products" >}}#tracking-inventory) guide).
+* **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).

+ 1 - 1
docs/docs/user-guide/settings/taxes.md

@@ -29,4 +29,4 @@ Tax rates set the rate of tax for a given **tax category** destined for a partic
 
 ## 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]({{< relref "/guides/developer-guide/taxes" >}}). 
+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/). 

+ 7 - 3
docs/docusaurus.config.js

@@ -45,9 +45,7 @@ const config = {
                         keywords: ['cli'],
                         extendDefaults: true,
                     },
-                    exclude: [
-                        'user-guide/**/*'
-                    ]
+                    // exclude: ['user-guide/**/*'],
                 },
                 blog: false,
                 theme: {
@@ -92,6 +90,12 @@ const config = {
                         position: 'left',
                         label: 'Reference',
                     },
+                    {
+                        type: 'docSidebar',
+                        sidebarId: 'userGuideSidebar',
+                        position: 'left',
+                        label: 'User Guide',
+                    },
                     {
                         href: 'https://vendure.io',
                         label: 'vendure.io',

+ 32 - 0
docs/sidebars.js

@@ -315,6 +315,38 @@ const sidebars = {
             },
         },
     ],
+    userGuideSidebar: [
+        {
+            type: 'doc',
+            id: 'user-guide/index',
+            className: 'reference-index',
+        },
+        {
+            type: 'category',
+            label: 'Catalog',
+            items: [{ type: 'autogenerated', dirName: 'user-guide/catalog' }],
+        },
+        {
+            type: 'category',
+            label: 'Orders',
+            items: [{ type: 'autogenerated', dirName: 'user-guide/orders' }],
+        },
+        {
+            type: 'category',
+            label: 'Customers',
+            items: [{ type: 'autogenerated', dirName: 'user-guide/customers' }],
+        },
+        {
+            type: 'category',
+            label: 'Promotions',
+            items: [{ type: 'autogenerated', dirName: 'user-guide/promotions' }],
+        },
+        {
+            type: 'category',
+            label: 'Settings',
+            items: [{ type: 'autogenerated', dirName: 'user-guide/settings' }],
+        },
+    ],
 };
 
 module.exports = sidebars;

+ 64 - 0
license/signatures/version1/cla.json

@@ -39,6 +39,70 @@
       "created_at": "2024-07-19T06:53:24Z",
       "repoId": 136938012,
       "pullRequestNo": 2964
+    },
+    {
+      "name": "williamrijksen",
+      "id": 1991582,
+      "comment_id": 2250303402,
+      "created_at": "2024-07-25T13:17:48Z",
+      "repoId": 136938012,
+      "pullRequestNo": 2972
+    },
+    {
+      "name": "monrostar",
+      "id": 13255191,
+      "comment_id": 2255645619,
+      "created_at": "2024-07-29T11:09:26Z",
+      "repoId": 136938012,
+      "pullRequestNo": 2978
+    },
+    {
+      "name": "jacobfrantz1",
+      "id": 69358280,
+      "comment_id": 2258961522,
+      "created_at": "2024-07-30T18:31:00Z",
+      "repoId": 136938012,
+      "pullRequestNo": 2982
+    },
+    {
+      "name": "arrrrny",
+      "id": 48218623,
+      "comment_id": 2262149965,
+      "created_at": "2024-08-01T06:25:53Z",
+      "repoId": 136938012,
+      "pullRequestNo": 2987
+    },
+    {
+      "name": "casperiv0",
+      "id": 53900565,
+      "comment_id": 2267440518,
+      "created_at": "2024-08-04T08:52:46Z",
+      "repoId": 136938012,
+      "pullRequestNo": 2993
+    },
+    {
+      "name": "DanielBiegler",
+      "id": 14810858,
+      "comment_id": 2267490248,
+      "created_at": "2024-08-04T10:20:47Z",
+      "repoId": 136938012,
+      "pullRequestNo": 2994
+    },
+    {
+      "name": "dfernandesbsolus",
+      "id": 96054351,
+      "comment_id": 2277331192,
+      "created_at": "2024-08-09T07:30:28Z",
+      "repoId": 136938012,
+      "pullRequestNo": 3002
+    },
+    {
+      "name": "Dominic-Preap",
+      "id": 14802170,
+      "comment_id": 2281685611,
+      "created_at": "2024-08-10T13:11:42Z",
+      "repoId": 136938012,
+      "pullRequestNo": 3007
     }
   ]
 }

+ 0 - 26
package-lock.json

@@ -9893,11 +9893,6 @@
             "dev": true,
             "license": "MIT"
         },
-        "node_modules/@types/detect-port": {
-            "version": "1.3.5",
-            "dev": true,
-            "license": "MIT"
-        },
         "node_modules/@types/duplexify": {
             "version": "3.6.4",
             "dev": true,
@@ -11588,13 +11583,6 @@
             "dev": true,
             "license": "MIT"
         },
-        "node_modules/address": {
-            "version": "1.2.2",
-            "license": "MIT",
-            "engines": {
-                "node": ">= 10.0.0"
-            }
-        },
         "node_modules/adjust-sourcemap-loader": {
             "version": "4.0.0",
             "license": "MIT",
@@ -15031,18 +15019,6 @@
             "version": "2.1.0",
             "license": "MIT"
         },
-        "node_modules/detect-port": {
-            "version": "1.5.1",
-            "license": "MIT",
-            "dependencies": {
-                "address": "^1.0.1",
-                "debug": "4"
-            },
-            "bin": {
-                "detect": "bin/detect-port.js",
-                "detect-port": "bin/detect-port.js"
-            }
-        },
         "node_modules/dev-server": {
             "resolved": "packages/dev-server",
             "link": true
@@ -32329,7 +32305,6 @@
                 "@vendure/common": "^3.0.0",
                 "commander": "^11.0.0",
                 "cross-spawn": "^7.0.3",
-                "detect-port": "^1.5.1",
                 "fs-extra": "^11.2.0",
                 "handlebars": "^4.7.8",
                 "picocolors": "^1.0.0",
@@ -32341,7 +32316,6 @@
             },
             "devDependencies": {
                 "@types/cross-spawn": "^6.0.6",
-                "@types/detect-port": "^1.3.5",
                 "@types/fs-extra": "^11.0.4",
                 "@types/handlebars": "^4.1.0",
                 "@types/semver": "^7.5.8",

+ 4 - 5
packages/admin-ui/src/lib/catalog/src/components/create-product-variant-dialog/create-product-variant-dialog.component.ts

@@ -1,5 +1,5 @@
 import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
-import { FormBuilder, FormControl, FormGroup, FormRecord, Validators } from '@angular/forms';
+import { FormBuilder, FormControl, FormRecord, Validators } from '@angular/forms';
 import {
     CreateProductVariantInput,
     CurrencyCode,
@@ -7,7 +7,6 @@ import {
     GetProductVariantOptionsQuery,
 } from '@vendure/admin-ui/core';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
-import { combineLatest } from 'rxjs';
 
 @Component({
     selector: 'vdr-create-product-variant-dialog',
@@ -20,7 +19,7 @@ export class CreateProductVariantDialogComponent implements Dialog<CreateProduct
     product: NonNullable<GetProductVariantOptionsQuery['product']>;
     form = this.formBuilder.group({
         name: ['', Validators.required],
-        sku: ['', Validators.required],
+        sku: [''],
         price: [''],
         options: this.formBuilder.record<string>({}),
     });
@@ -67,14 +66,14 @@ export class CreateProductVariantDialogComponent implements Dialog<CreateProduct
 
     confirm() {
         const { name, sku, options, price } = this.form.value;
-        if (!name || !sku || !options || price == null) {
+        if (!name || !options || price == null) {
             return;
         }
 
         const optionIds = Object.values(options).filter(notNullOrUndefined);
         this.resolveWith({
             productId: this.product.id,
-            sku,
+            sku: sku || '',
             price: Number(price),
             optionIds,
             translations: [

+ 1 - 1
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.scss

@@ -53,7 +53,7 @@ vdr-action-bar clr-toggle-wrapper {
 
 .channel-assignment {
     flex-wrap: wrap;
-    max-height: 144px;
+    min-height: 24px;
 }
 
 .pagination-row {

+ 2 - 2
packages/admin-ui/src/lib/core/src/shared/pipes/locale-date.pipe.spec.ts

@@ -23,11 +23,11 @@ describe('LocaleDatePipe', () => {
 
     it('medium format German', () => {
         const pipe = new LocaleDatePipe();
-        expect(pipe.transform(testDate, 'medium', LanguageCode.de)).toBe('12. Jan. 2021, 9:12:42 AM');
+        expect(pipe.transform(testDate, 'medium', LanguageCode.de)).toBe('12. Jan. 2021, 09:12:42');
     });
 
     it('medium format Chinese', () => {
         const pipe = new LocaleDatePipe();
-        expect(pipe.transform(testDate, 'medium', LanguageCode.zh)).toBe('2021年1月12日 上午9:12:42');
+        expect(pipe.transform(testDate, 'medium', LanguageCode.zh)).toBe('2021年1月12日 09:12:42');
     });
 });

+ 0 - 3
packages/admin-ui/src/lib/core/src/shared/pipes/locale-date.pipe.ts

@@ -47,14 +47,12 @@ export class LocaleDatePipe extends LocaleBasePipe implements PipeTransform {
                     hour: 'numeric',
                     minute: 'numeric',
                     second: 'numeric',
-                    hour12: true,
                 };
             case 'mediumTime':
                 return {
                     hour: 'numeric',
                     minute: 'numeric',
                     second: 'numeric',
-                    hour12: true,
                 };
             case 'longDate':
                 return {
@@ -75,7 +73,6 @@ export class LocaleDatePipe extends LocaleBasePipe implements PipeTransform {
                     year: '2-digit',
                     hour: 'numeric',
                     minute: 'numeric',
-                    hour12: true,
                 };
             default:
                 return;

+ 8 - 8
packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json

@@ -28,7 +28,7 @@
     "administrators": "Administradores",
     "assets": "Imagens",
     "channels": "Canais",
-    "collections": "Categorias",
+    "collections": "Coleções",
     "countries": "Países",
     "customer-groups": "Grupos de cliente",
     "customers": "Clientes",
@@ -76,7 +76,7 @@
     "cannot-create-variants-without-options": "As variantes do produto não podem ser criadas até que um grupo de opções com pelo menos duas opções de produtos tenha sido definidas",
     "channel-price-preview": "Visualizar preço do canal",
     "collection": "Coleçāo",
-    "collection-contents": "Conteúdo da categoria",
+    "collection-contents": "Conteúdo da coleção",
     "collections": "Coleções",
     "confirm-bulk-delete-products": "Excluir {count} produtos?",
     "confirm-cancel": "Cancelar?",
@@ -91,7 +91,7 @@
     "confirm-deletion-of-unused-variants-title": "Excluir variantes de produtos obsoletos?",
     "create-draft-order": "Criar rascunho de pedido",
     "create-facet-value": "Criar novo valor para etiqueta",
-    "create-new-collection": "Criar nova categoria",
+    "create-new-collection": "Criar nova coleção",
     "create-new-facet": "Criar nova etiqueta",
     "create-new-product": "Novo produto",
     "create-new-stock-location": "Criar nova localização de estoque",
@@ -274,7 +274,7 @@
     "name": "Nome",
     "no-alerts": "Sem alertas",
     "no-bulk-actions-available": "Nenhuma ação em massa disponível",
-    "no-channel-selected": "",
+    "no-channel-selected": "Nenhum canal selecionado",
     "no-results": "Sem resultados",
     "not-applicable": "Não aplicável",
     "not-set": "Não configurado",
@@ -480,10 +480,10 @@
     "image-title": "Título",
     "insert-image": "Inserir imagem",
     "link-href": "Link de referência",
-    "link-target": "",
+    "link-target": "Escolha onde abrir o link:",
     "link-title": "Título do link",
     "remove-link": "Remover",
-    "set-link": "Setar link"
+    "set-link": "Definir link"
   },
   "error": {
     "403-forbidden": "No momento, você não está autorizado a acessar \"{ path }\". Você não tem permissão ou sua sessão expirou.",
@@ -514,7 +514,7 @@
     "assets": "Imagens",
     "catalog": "Catálogo",
     "channels": "Canais",
-    "collections": "Categorias",
+    "collections": "Coleções",
     "countries": "Países",
     "customer-groups": "Grupos de clientes",
     "customers": "Clientes",
@@ -675,7 +675,7 @@
     "set-billing-address": "Definir endereço de faturamento",
     "set-coupon-codes": "Definir cupons de desconto",
     "set-customer-for-order": "Definir cliente",
-    "set-customer-success": "",
+    "set-customer-success": "Cliente definido com sucesso",
     "set-fulfillment-state": "Marcar como {state}",
     "set-shipping-address": "Definir endereço de entrega",
     "set-shipping-method": "Definir método de envio",

+ 160 - 160
packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json

@@ -28,7 +28,7 @@
     "administrators": "Administradores",
     "assets": "Imagens",
     "channels": "Canais",
-    "collections": "Categorias",
+    "collections": "Colecções",
     "countries": "Países",
     "customer-groups": "Grupos de cliente",
     "customers": "Clientes",
@@ -45,8 +45,8 @@
     "profile": "Perfil",
     "promotions": "Promoções",
     "roles": "Regras",
-    "seller-orders": "",
-    "sellers": "",
+    "seller-orders": "Encomendas de vendedores",
+    "sellers": "Vendedores",
     "shipping-methods": "Métodos de envio",
     "stock-locations": "Localizações de stock",
     "system-status": "Estado do sistema",
@@ -58,12 +58,12 @@
     "add-facet-value": "Adicionar novo valor",
     "add-facets": "Adicionar etiqueta",
     "add-option": "Adicionar opção",
-    "add-price-in-another-currency": "",
-    "add-stock-location": "",
-    "add-stock-to-location": "",
-    "asset": "",
-    "asset-preview-links": "",
-    "assets": "",
+    "add-price-in-another-currency": "Adicionar preço em outra moeda",
+    "add-stock-location": "Adicionar Localização de stock",
+    "add-stock-to-location": "Adicionar Stock a localização",
+    "asset": "Imagem",
+    "asset-preview-links": "Links de pré vizualização de imagens",
+    "assets": "Imagens",
     "assign-product-to-channel-success": "Produto atribuído com sucesso a \"{ channel }\"",
     "assign-products-to-channel": "Atribuir produtos ao canal",
     "assign-to-named-channel": "Atribuir a { channelCode }",
@@ -73,74 +73,74 @@
     "auto-update-product-variant-name": "Actualizar automaticamente os nomes das variantes do produto",
     "calculated-price": "Preço calculado",
     "calculated-price-tooltip": "Existe um cálculo de preço personalizado configurado que modifica o preço definido acima:",
-    "cannot-create-variants-without-options": "",
+    "cannot-create-variants-without-options": "Não é possível criar variantes do produto sem opções",
     "channel-price-preview": "Visualizar preço do canal",
-    "collection": "",
-    "collection-contents": "Conteúdo da categoria",
-    "collections": "",
-    "confirm-bulk-delete-products": "",
-    "confirm-cancel": "",
+    "collection": "Colecção",
+    "collection-contents": "Conteúdo da colecção",
+    "collections": "Colecções",
+    "confirm-bulk-delete-products": "Confirmar apagar produtos em massa",
+    "confirm-cancel": "Confirmar cancelamento",
     "confirm-delete-assets": "Eliminar {count} {count, plural, one {imagem} other {imagens}}?",
     "confirm-delete-facet-value": "Eliminar valor da etiqueta?",
     "confirm-delete-product": "Eliminar produto?",
-    "confirm-delete-product-option": "",
-    "confirm-delete-product-option-group": "",
-    "confirm-delete-product-option-group-body": "",
+    "confirm-delete-product-option": "Eliminar opção de produto?",
+    "confirm-delete-product-option-group": "Eliminar grupo de opção de produto?",
+    "confirm-delete-product-option-group-body": "Eliminar grupo de opção de produto?",
     "confirm-delete-product-variant": "Eliminar variante do produto?",
     "confirm-deletion-of-unused-variants-body": "As variantes listadas abaixo estão obsoletas e serão eliminadas devido à adição de novas opções.",
     "confirm-deletion-of-unused-variants-title": "Eliminar as variantes obsoletas?",
-    "create-draft-order": "",
-    "create-facet-value": "",
-    "create-new-collection": "Criar nova categoria",
+    "create-draft-order": "Criar ordem rascunho",
+    "create-facet-value": "Criar valor de imagem",
+    "create-new-collection": "Criar nova colecção",
     "create-new-facet": "Criar nova etiqueta",
     "create-new-product": "Novo produto",
-    "create-new-stock-location": "",
-    "create-product-option-group": "",
-    "create-product-variant": "",
-    "default-currency": "",
-    "do-not-inherit-filters": "",
+    "create-new-stock-location": "Criar nova localização de stock",
+    "create-product-option-group": "Criar grupo de opção de produto",
+    "create-product-variant": "Criar variante de produto",
+    "default-currency": "Moeda padrão",
+    "do-not-inherit-filters": "Não herdar filtros",
     "drop-files-to-upload": "Colocar ficheiros para enviar",
-    "duplicate-collections": "Duplicar coleções",
+    "duplicate-collections": "Duplicar colecções",
     "duplicate-facets": "Duplicar facetas",
     "duplicate-products": "Duplicar produtos",
-    "edit-facet-values": "",
-    "edit-options": "",
-    "facet": "",
-    "facet-value-not-available": "",
+    "edit-facet-values": "Editar etiquetas",
+    "edit-options": "Editar Opções",
+    "facet": "Etiqueta",
+    "facet-value-not-available": "Valor da etiqueta não disponível",
     "facet-values": "Valor da Etiqueta",
-    "facets": "",
+    "facets": "Etiquetas",
     "filter-by-name": "Filtrar por nome",
-    "filter-inheritance": "",
+    "filter-inheritance": "Herdar filtros",
     "filters": "Filtros",
-    "inherit-filters-from-parent": "",
-    "live-preview-contents": "",
+    "inherit-filters-from-parent": "Herdar filtros do produto pai",
+    "live-preview-contents": "Pré vizualizar conteúdos",
     "manage-variants": "Gerir variações",
-    "move-collection-to": "",
-    "move-collections": "",
-    "move-collections-success": "",
+    "move-collection-to": "Mover colecção para",
+    "move-collections": "Mover Colecções",
+    "move-collections-success": "Colecções movidas com sucesso",
     "move-down": "Mover para baixo",
     "move-to": "Mover para",
     "move-up": "Mover para cima",
-    "name": "",
+    "name": "Nome",
     "no-channel-selected": "Nenhum canal seleccionado",
     "no-featured-asset": "Nenhum recurso em destaque",
     "no-selection": "Nenhuma imagem seleccionada",
-    "no-stock-locations-available-on-current-channel": "",
-    "notify-bulk-delete-products-success": "",
-    "notify-remove-facets-from-channel-success": "",
+    "no-stock-locations-available-on-current-channel": "Nenhuma localização de stock disponível neste canal",
+    "notify-bulk-delete-products-success": "Produtos em bulk apagados com sucesso",
+    "notify-remove-facets-from-channel-success": "Etiquetas removidas do canal com sucesso",
     "notify-remove-product-from-channel-error": "Não foi possível remover o produto do canal",
     "notify-remove-product-from-channel-success": "Produto removido do canal com sucesso",
     "notify-remove-variant-from-channel-error": "Erro ao remover a variante do canal",
     "notify-remove-variant-from-channel-success": "Variante removida do canal com sucesso",
-    "number-of-variants": "",
+    "number-of-variants": "Quantidade de variantes",
     "option": "Opção",
     "option-name": "Nome da opção",
     "option-values": "Valor da opção",
     "out-of-stock-threshold": "Limite para fora de estoque",
     "out-of-stock-threshold-tooltip": "Define o limite para a variante ser considerada sem estoque. Usar um valor negativo activa o suporte a pedidos pendentes.",
-    "page-description-options-editor": "",
+    "page-description-options-editor": "Editar opções de descrição de página",
     "price": "Preço",
-    "price-and-tax": "",
+    "price-and-tax": "Preço e impostos",
     "price-conversion-factor": "Factor de conversão de preço",
     "price-in-channel": "Preço no canal { channel }",
     "price-includes-tax-at": "Inclui { rate }% de imposto",
@@ -153,7 +153,7 @@
     "product-variants": "Variações do produto",
     "products": "Produtos",
     "public": "Público",
-    "quick-jump-placeholder": "",
+    "quick-jump-placeholder": "Avanço rápido",
     "rebuild-search-index": "Reconstruir índice de pesquisa",
     "reindex-error": "Ocorreu um erro ao reconstruir o índice de pesquisa",
     "reindex-successful": "{count, plural, one {Variante do produto indexada} other {{count} variantes de produtos indexadas}} em {time}ms",
@@ -162,9 +162,9 @@
     "remove-option": "Eliminar opção",
     "remove-product-from-channel": "Eliminar produto do canal",
     "remove-product-variant-from-channel": "Remover variante do canal?",
-    "reorder-collection": "",
-    "root-collection": "",
-    "run-pending-search-index-updates": "",
+    "reorder-collection": "Reordenar colecção",
+    "root-collection": "Voltar atrás na colecçáo",
+    "run-pending-search-index-updates": "Fazer update a indexações de pesquisa pendentes",
     "running-search-index-updates": "A executar {count, plural, one {1 actualização} other {{count} actualizações}}",
     "search-asset-name-or-tag": "Pesquisar pelo nome ou tag",
     "search-for-term": "Pesquisar termo",
@@ -175,7 +175,7 @@
     "slug": "Slug",
     "slug-pattern-error": "Slug inválido",
     "stock-allocated": "Estoque reservado",
-    "stock-levels": "",
+    "stock-levels": "Níveis de Stock",
     "stock-location": "Localização de stock",
     "stock-locations": "Localizações de stock",
     "stock-on-hand": "Estoque",
@@ -190,41 +190,41 @@
     "use-global-value": "Utilizar configuração global",
     "values": "Valores",
     "variant": "Variante",
-    "variant-count": "",
+    "variant-count": "Número de variantes",
     "view-contents": "Visualizar conteúdo",
     "visibility": "Visibilidade"
   },
   "common": {
     "ID": "ID",
-    "add-filter": "",
+    "add-filter": "Adicionar filtro",
     "add-item-to-list": "Adicionar item à lista",
     "add-note": "Adicionar nota",
-    "apply": "",
-    "assign-to-channel": "",
+    "apply": "Aplicar",
+    "assign-to-channel": "Adicionar a canal",
     "assign-to-channels": "Atribuir a {count, plural, one {canal} other {canais}}",
-    "available-currencies": "",
+    "available-currencies": "Moedas disponíveis",
     "available-languages": "Idiomas disponíveis",
-    "boolean-and": "",
-    "boolean-false": "",
-    "boolean-or": "",
-    "boolean-true": "",
-    "breadcrumb": "",
+    "boolean-and": "e",
+    "boolean-false": "falso",
+    "boolean-or": "ou",
+    "boolean-true": "verdadeiro",
+    "breadcrumb": "breadcrump",
     "browser-default": "Navegador padrão",
     "cancel": "Cancelar",
     "cancel-navigation": "Continuar a editar",
     "change-selection": "Alterar seleccionados",
     "channel": "Canal",
     "channels": "Canais",
-    "clear-selection": "",
+    "clear-selection": "Apagar seleção",
     "code": "Código",
     "collapse-entries": "Recolher entradas",
     "confirm": "Confirmar",
-    "confirm-bulk-assign-to-channel": "",
-    "confirm-bulk-delete": "",
-    "confirm-bulk-remove-from-channel": "",
+    "confirm-bulk-assign-to-channel": "Confirmar atribuição em bulk a canal",
+    "confirm-bulk-delete": "Confimar apagar em bulk",
+    "confirm-bulk-remove-from-channel": "Confirmar apagar em bulk de canal",
     "confirm-delete-note": "Eliminar nota?",
     "confirm-navigation": "Descartar modificações?",
-    "contents": "",
+    "contents": "Conteúdos",
     "create": "Adicionar",
     "created-at": "Adicionado em",
     "custom-fields": "Campos customizados",
@@ -244,97 +244,97 @@
     "edit-field": "Editar campo",
     "edit-note": "Editar nota",
     "enabled": "Activo",
-    "end-date": "",
+    "end-date": "Data de fim",
     "expand-entries": "Expandir entradas",
     "extension-running-in-separate-window": "A extensão está a ser executada em uma janela separada",
     "filter": "Filtro",
     "filter-preset-name": "Nome de configuração predefinida de filtro",
-    "force-delete": "",
-    "force-remove": "",
+    "force-delete": "Forçar apagar",
+    "force-remove": "Forçar remover",
     "general": "Geral",
     "guest": "Convidado",
-    "id": "",
-    "image": "",
+    "id": "ID",
+    "image": "Imagem",
     "items-per-page-option": "{ count } por página",
-    "items-selected-count": "",
-    "keep-editing": "",
+    "items-selected-count": "Itens seleccionados",
+    "keep-editing": "Continuar edição",
     "language": "Idioma",
     "launch-extension": "Iniciar extensão",
-    "list-items-and-n-more": "",
+    "list-items-and-n-more": "Listar itens",
     "live-update": "Actualização em tempo real",
     "locale": "Localidade",
     "log-out": "Sair",
     "login": "Entrar",
-    "login-image-title": "",
-    "login-title": "",
+    "login-image-title": "Titulo de imagem de login",
+    "login-title": "Titulo de login",
     "manage-tags": "Gerir tags",
     "manage-tags-description": "Atualize ou elimine tags globalmente.",
     "medium-date": "Data média",
     "more": "Mais...",
     "name": "Nome",
-    "no-alerts": "",
-    "no-bulk-actions-available": "",
-    "no-channel-selected": "",
+    "no-alerts": "sem alertas",
+    "no-bulk-actions-available": "nenhuma acção em bulk disponível",
+    "no-channel-selected": "Nenhum canal selecionado",
     "no-results": "Nenhum resultado encontrado",
-    "not-applicable": "",
+    "not-applicable": "Não aplicável",
     "not-set": "Não configurado",
-    "notify-assign-to-channel-success-with-count": "",
-    "notify-bulk-update-success": "",
+    "notify-assign-to-channel-success-with-count": "Notificar atribuição a canal com contagem",
+    "notify-bulk-update-success": "Notificar bulk update com sucesso",
     "notify-create-error": "Ocorreu um erro. Não foi possível criar { entity }",
     "notify-create-success": "Novo(a) { entity } adicionado(a)",
     "notify-delete-error": "Ocorreu um erro, não foi possível eliminar { entity }",
-    "notify-delete-error-with-count": "",
+    "notify-delete-error-with-count": "Notificar erro ao apagar com contagem",
     "notify-delete-success": "{ entity } excluído(a)",
-    "notify-delete-success-with-count": "",
+    "notify-delete-success-with-count": "Notificar apagado com sucesso com contagem",
     "notify-duplicate-error": "Não foi possível duplicar { name } devido a um erro: { error }",
     "notify-duplicate-error-excess": "Não foi possível duplicar { count } {count, plural, one {item} other {itens}} adicionais devido a erros",
     "notify-duplicate-success": "Duplicação realizada com sucesso {count, plural, one {1 item} other {{count} itens}}: { names }",
-    "notify-remove-from-channel-success-with-count": "",
+    "notify-remove-from-channel-success-with-count": "Notificar removido do canal com sucesso com contagem",
     "notify-save-changes-error": "Ocorreu um erro. Não foi possível guardar as alterações",
     "notify-saved-changes": "Alterações guardadas",
     "notify-update-error": "Ocorreu um erro. Não foi possível actualizar a entidade { entity }",
     "notify-update-success": "Entidade ({ entity }) actualizada com sucesso",
     "notify-updated-tags-success": "Tags actualizadas com sucesso",
-    "okay": "",
-    "operator-contains": "",
-    "operator-eq": "",
-    "operator-gt": "",
-    "operator-lt": "",
-    "operator-not-contains": "",
-    "operator-not-eq": "",
-    "operator-notContains": "",
-    "operator-regex": "",
+    "okay": "Ok",
+    "operator-contains": "Contém",
+    "operator-eq": "Igual",
+    "operator-gt": "Maior que",
+    "operator-lt": "Menor que",
+    "operator-not-contains": "Não contém",
+    "operator-not-eq": "Diferente de",
+    "operator-notContains": "Não Contêm",
+    "operator-regex": "Expressão regular",
     "password": "Palavra passe",
-    "position": "",
+    "position": "Posição",
     "price": "Preço",
     "price-with-tax": "Preço com impostos",
     "private": "Privado",
     "public": "Público",
     "remember-me": "Lembre-se de mim",
     "remove": "Eliminar",
-    "remove-from-channel": "",
+    "remove-from-channel": "Remover do canal",
     "remove-item-from-list": "Remover item da lista",
     "rename-filter-preset": "Renomear predefinição",
-    "reset-columns": "",
+    "reset-columns": "Apagar colunas",
     "results-count": "{ count } {count, plural, one {resultado} other {resultados}}",
     "sample-formatting": "Formatação de amostra",
     "save-filter-preset": "Guardar como predefinição",
-    "search-and-filter-list": "",
-    "search-by-name": "",
+    "search-and-filter-list": "Pesquisa e lista de filtros",
+    "search-by-name": "Pesquisar por nome",
     "select": "Seleccione...",
     "select-display-language": "Seleccionar idioma",
-    "select-items-with-count": "",
-    "select-products": "",
-    "select-relation-id": "",
-    "select-table-columns": "",
+    "select-items-with-count": "Selecionar itens com contagem",
+    "select-products": "Selecionar produtos",
+    "select-relation-id": "Seçecionar relacionamento da ID",
+    "select-table-columns": "Selecionar tabelas comuns",
     "select-today": "Seleccione a data de hoje",
-    "select-variants": "",
-    "seller": "",
+    "select-variants": "Selecionar variantes",
+    "seller": "Vendedor",
     "set-language": "Definir idioma",
     "short-date": "Data abreviada",
-    "slug": "",
-    "start-date": "",
-    "status": "",
+    "slug": "Slug",
+    "start-date": "Data de início",
+    "status": "Estado",
     "tags": "Tags",
     "theme": "Tema",
     "there-are-unsaved-changes": "Há alterações por guardar. Navegar para outra página fará com que essas alterações sejam perdidas.",
@@ -343,11 +343,11 @@
     "update": "Actualização",
     "updated-at": "Actualizado em",
     "username": "Nome do utilizador",
-    "value": "",
-    "view-contents": "",
+    "value": "Valor",
+    "view-contents": "Ver conteúdos",
     "view-next-month": "Visualizar próximo mês",
     "view-previous-month": "Visualizar mês anterior",
-    "visibility": "",
+    "visibility": "visibilidade",
     "with-selected": "Com seleccionado..."
   },
   "customer": {
@@ -359,18 +359,18 @@
     "add-customers-to-group-with-name": "Clientes adicionados ao grupo \"{ groupName }\"",
     "addresses": "Moradas",
     "city": "Cidade",
-    "company": "",
+    "company": "Empresa",
     "confirm-remove-customer-from-group": "Eliminar cliente do grupo?",
     "country": "País",
     "create-customer-group": "Criar grupo de cliente",
     "create-new-address": "Criar nova morada",
     "create-new-customer": "Criar novo cliente",
     "create-new-customer-group": "Criar novo grupo de cliente",
-    "customer": "",
-    "customer-group": "",
+    "customer": "Cliente",
+    "customer-group": "Grupo de cliente",
     "customer-groups": "Grupos de cliente",
     "customer-history": "Histórico de cliente",
-    "customers": "",
+    "customers": "Clientes",
     "default-billing-address": "Morada de cobrança padrão",
     "default-shipping-address": "Morada de entrega padrão",
     "email-address": "E-mail",
@@ -423,10 +423,10 @@
   "dashboard": {
     "add-widget": "Adicionar widget",
     "latest-orders": "Últimas encomendas",
-    "metric-average-order-value": "",
-    "metric-number-of-orders": "",
-    "metric-order-total-value": "",
-    "metrics": "",
+    "metric-average-order-value": "Valor médio de encomenda",
+    "metric-number-of-orders": "Número de encomendas",
+    "metric-order-total-value": "Valor total de encomendas",
+    "metrics": "Métricas",
     "orders-summary": "Resumo de encomendas",
     "remove-widget": "Remover widget",
     "thisMonth": "Este mês",
@@ -480,7 +480,7 @@
     "image-title": "Título",
     "insert-image": "Inserir imagem",
     "link-href": "Link de referência",
-    "link-target": "",
+    "link-target": "Escolha onde abrir o link",
     "link-title": "Título do link",
     "remove-link": "Remover",
     "set-link": "Atribuir link"
@@ -503,8 +503,8 @@
     "ends-at": "Válido até",
     "per-customer-limit": "Limite por cliente",
     "per-customer-limit-tooltip": "Número máximo de vezes que esta promoção pode ser utilizada por um único cliente",
-    "promotion": "",
-    "search-by-name-or-coupon-code": "",
+    "promotion": "Promoção",
+    "search-by-name-or-coupon-code": "Pesquisar por nome ou código de cupão",
     "starts-at": "Válido a partir",
     "usage-limit": "Limite total de utilização",
     "usage-limit-tooltip": "Número máximo de vezes que esta promoção pode ser utilizada no total"
@@ -514,7 +514,7 @@
     "assets": "Imagens",
     "catalog": "Catálogo",
     "channels": "Canais",
-    "collections": "Categorias",
+    "collections": "Colecções",
     "countries": "Países",
     "customer-groups": "Grupos",
     "customers": "Clientes",
@@ -528,7 +528,7 @@
     "promotions": "Promoções",
     "roles": "Gerir permissões",
     "sales": "Vendas",
-    "sellers": "",
+    "sellers": "Vendedores",
     "settings": "Configurações",
     "shipping-methods": "Métodos de envio",
     "stock-locations": "Localizações de stock",
@@ -551,7 +551,7 @@
     "assign-order-to-another-customer": "Atribuir pedido a outro cliente",
     "billing-address": "Morada de faturação",
     "cancel": "Cancelar",
-    "cancel-entire-order": "",
+    "cancel-entire-order": "Cancelar encomenda completa",
     "cancel-fulfillment": "Cancelar entrega",
     "cancel-modification": "Cancelar modificação",
     "cancel-order": "Cancelar encomenda",
@@ -559,24 +559,24 @@
     "cancel-reason-customer-request": "Encomenda do cliente",
     "cancel-reason-not-available": "Não disponível",
     "cancel-selected-items": "Cancelar itens seleccionados",
-    "cancel-specified-items": "",
+    "cancel-specified-items": "Cancelar itens específicos",
     "cancellation-reason": "Motivo do cancelamento",
     "cancelled-order-items-success": "Itens da encomenda cancelados { count } { count, plural, one {item} other {itens} }",
     "cancelled-order-success": "Encomenda cancelada com sucesso",
-    "complete-draft-order": "",
+    "complete-draft-order": "Completar ordem rascunho",
     "confirm-modifications": "Confirmar modificações",
     "contents": "Conteúdo",
     "create-fulfillment": "Criar entrega",
     "create-fulfillment-success": "O processo entrega foi criado com sucesso",
     "customer": "Cliente",
-    "delete-draft-order": "",
-    "draft-order": "",
+    "delete-draft-order": "Apagar ordem rascunho",
+    "draft-order": "Ordem rascunho",
     "edit-billing-address": "Editar morada de faturação",
     "edit-shipping-address": "Editar morada de entrega",
     "error-message": "Mensagem de erro",
-    "existing-address": "",
-    "existing-customer": "",
-    "filter-is-active": "",
+    "existing-address": "Morada existente",
+    "existing-customer": "Cliente existente",
+    "filter-is-active": "Filtro está activo",
     "fulfill": "Enviar",
     "fulfill-order": "Enviar a encomenda",
     "fulfillment": "Entrega",
@@ -622,22 +622,22 @@
     "note-is-private": "Marcar como privada",
     "note-only-visible-to-administrators": "Visível somente para administradores",
     "note-visible-to-customer": "Visível para administradores e clientes",
-    "order": "",
+    "order": "Encomenda",
     "order-history": "Histórico de encomendas",
-    "order-is-empty": "",
+    "order-is-empty": "Encomenda está vazia",
     "order-state-diagram": "Diagrama do estado da encomenda",
-    "order-type": "",
-    "order-type-aggregate": "",
-    "order-type-regular": "",
-    "order-type-seller": "",
-    "orders": "",
+    "order-type": "Tipo de encomenda",
+    "order-type-aggregate": "Encomenda agregada",
+    "order-type-regular": "Encomenda normal",
+    "order-type-seller": "Encomenda de vendedor",
+    "orders": "Encomendas",
     "original-quantity-at-checkout": "Quantidade original no checkout",
     "payment": "Pagamento",
     "payment-amount": "Valor do pagamento",
     "payment-metadata": "Dados do pagamento",
     "payment-method": "Método de pagamento",
     "payment-state": "Estado",
-    "payments": "",
+    "payments": "Pagamentos",
     "placed-at": "Adicionada em",
     "preview-changes": "Revisar mudanças",
     "previous-customer": "Cliente anterior",
@@ -668,17 +668,17 @@
     "removed-items": "Itens removidos",
     "return-to-stock": "Devolver ao stock",
     "search-by-order-filters": "Pesqusiar pelo código da encomenda / apelido do cliente / ID da transação",
-    "select-address": "",
-    "select-shipping-method": "",
+    "select-address": "Selecionar morada",
+    "select-shipping-method": "Selecionar método de envio",
     "select-state": "Seleccionar estado",
-    "seller-orders": "",
-    "set-billing-address": "",
-    "set-coupon-codes": "",
-    "set-customer-for-order": "",
-    "set-customer-success": "",
+    "seller-orders": "Selecionar encomendas",
+    "set-billing-address": "Selecionar morada de facturação",
+    "set-coupon-codes": "Selecionar código de cupão",
+    "set-customer-for-order": "Selecionar cliente para encomenda",
+    "set-customer-success": "Cliente selecionado com sucesso",
     "set-fulfillment-state": "Marcar como {state}",
-    "set-shipping-address": "",
-    "set-shipping-method": "",
+    "set-shipping-address": "Selecionar morada de envio",
+    "set-shipping-method": "Selecionar método de envio",
     "settle-payment": "Liquidar pagamento",
     "settle-payment-error": "Não foi possível liquidar o pagamento",
     "settle-payment-success": "Pagamento liquidado com sucesso",
@@ -687,7 +687,7 @@
     "settle-refund-success": "Reembolso liquidado com sucesso",
     "shipping": "Envio",
     "shipping-address": "Morada de entrega",
-    "shipping-cancelled": "",
+    "shipping-cancelled": "Envio cancelado",
     "shipping-method": "Método de envio",
     "state": "Estado",
     "sub-total": "Subtotal",
@@ -712,30 +712,30 @@
     "add-countries-to-zone": "Adicionar paises para { zoneName }",
     "add-countries-to-zone-success": "A adicionar { countryCount } {countryCount, plural, one {país} other {países}} à região \"{ zoneName }\"",
     "add-products-to-test-order": "Adicionar produdos para a encomenda de teste",
-    "administrator": "",
+    "administrator": "Administrador",
     "channel": "Canal",
     "channel-token": "Token do canal",
-    "country": "",
+    "country": "País",
     "create-new-channel": "Criar novo canal",
     "create-new-country": "Criar novo país",
     "create-new-payment-method": "Criar novo método de pagamento",
     "create-new-role": "Criar nova regra",
-    "create-new-seller": "",
+    "create-new-seller": "Criar novo vendedor",
     "create-new-shipping-method": "Criar novo método de envio",
     "create-new-tax-category": "Criar categoria de imposto",
     "create-new-tax-rate": "Criar nova taxa de imposto",
     "create-new-zone": "Criar nova região",
-    "default-currency": "",
+    "default-currency": "Moeda padrão",
     "default-role-label": "Esta é uma regra padrão e não pode ser modificada",
     "default-shipping-zone": "Região de envio padrão",
     "default-tax-zone": "Região de imposto padrão",
-    "defaults": "",
+    "defaults": "Padrões",
     "eligible": "Elegível",
     "email-address": "E-mail",
-    "email-address-or-identifier": "",
+    "email-address-or-identifier": "Email ou identificador",
     "first-name": "Nome",
     "fulfillment-handler": "Manipulador para a execução de envio",
-    "global-available-languages-tooltip": "",
+    "global-available-languages-tooltip": "Linguagens globais disponíveis",
     "global-out-of-stock-threshold": "Limite globalpara fora de estoque",
     "global-out-of-stock-threshold-tooltip": "Define o limite para a variante ser considerada sem estoque. Usar um valor negativo activa o suporte a pedidos pendentes. Pode ser substituído pela variante do produto.",
     "last-name": "Apelido",
@@ -743,17 +743,17 @@
     "password": "Palavra passe",
     "payment-eligibility-checker": "Validação da elegibilidade do método",
     "payment-handler": "Manipulador do método",
-    "payment-method": "",
+    "payment-method": "Método de pagamento",
     "permissions": "Permissões",
     "prices-include-tax": "Os preços incluem impostos para a região padrão",
     "profile": "Perfil",
     "rate": "Taxa",
     "remove-countries-from-zone-success": "Eliminado { countryCount } {countryCount, plural, one {país} other {países}} da região \"{ zoneName }\"",
     "remove-from-zone": "Eliminar da região",
-    "role": "",
+    "role": "Regra",
     "roles": "Regras",
     "search-by-product-name-or-sku": "Pesquisa por nome do produto ou SKU",
-    "seller": "",
+    "seller": "Vendedor",
     "shipping-calculator": "Calculadora de envio",
     "shipping-eligibility-checker": "Validação da elegibilidade do método",
     "shipping-method": "Método de envio",
@@ -776,7 +776,7 @@
     "created": "Criado",
     "declined": "Recusado",
     "delivered": "Entregue",
-    "draft": "",
+    "draft": "Rascunho",
     "error": "Erro",
     "failed": "Falhou",
     "modifying": "A modificar",

+ 7 - 7
packages/common/src/shared-types.ts

@@ -12,8 +12,8 @@ export type DeepPartial<T> = {
         | (T[P] extends Array<infer U>
               ? Array<DeepPartial<U>>
               : T[P] extends ReadonlyArray<infer U>
-              ? ReadonlyArray<DeepPartial<U>>
-              : DeepPartial<T[P]>);
+                ? ReadonlyArray<DeepPartial<U>>
+                : DeepPartial<T[P]>);
 };
 /* eslint-enable no-shadow, @typescript-eslint/no-shadow */
 
@@ -53,8 +53,8 @@ export type JsonCompatible<T> = {
     [P in keyof T]: T[P] extends Json
         ? T[P]
         : Pick<T, P> extends Required<Pick<T, P>>
-        ? never
-        : JsonCompatible<T[P]>;
+          ? never
+          : JsonCompatible<T[P]>;
 };
 
 /**
@@ -95,7 +95,7 @@ export type ID = string | number;
  * datetime     | datetime (m,s), timestamp (p)         | DateTime
  * relation     | many-to-one / many-to-many relation   | As specified in config
  *
- * Additionally, the CustomFieldType also dictates which [configuration options](/reference/typescript-api/custom-fields/#configuration-options)
+ * Additionally, the CustomFieldType also dictates which [configuration options](/reference/typescript-api/custom-fields/#custom-field-config-properties)
  * are available for that custom field.
  *
  * @docsCategory custom-fields
@@ -219,7 +219,7 @@ export interface AdminUiConfig {
      * to. If set to "auto", the Admin UI app will determine the hostname from the
      * current location (i.e. `window.location.hostname`).
      *
-     * @default 'http://localhost'
+     * @default 'auto'
      */
     apiHost: string | 'auto';
     /**
@@ -228,7 +228,7 @@ export interface AdminUiConfig {
      * to. If set to "auto", the Admin UI app will determine the port from the
      * current location (i.e. `window.location.port`).
      *
-     * @default 3000
+     * @default 'auto'
      */
     apiPort: number | 'auto';
     /**

+ 1 - 0
packages/core/src/api/resolvers/shop/shop-order.resolver.ts

@@ -376,6 +376,7 @@ export class ShopOrderResolver {
             true,
         );
         return this.orderService.applyCouponCode(ctx, order.id, args.couponCode);
+        return this.orderService.applyCouponCode(ctx, order.id, args.couponCode);
     }
 
     @Transaction()

+ 2 - 3
packages/core/src/config/vendure-config.ts

@@ -401,9 +401,8 @@ export interface AuthOptions {
      * @description
      * Determines whether new User accounts require verification of their email address.
      *
-     * If set to "true", when registering via the `registerCustomerAccount` mutation, one should *not* set the
-     * `password` property - doing so will result in an error. Instead, the password is set at a later stage
-     * (once the email with the verification token has been opened) via the `verifyCustomerAccount` mutation.
+     * If set to "true", the customer will be required to verify their email address using a verification token
+     * they receive in their email. See the `registerCustomerAccount` mutation for more details on the verification behavior.
      *
      * @default true
      */

+ 6 - 1
packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts

@@ -200,11 +200,16 @@ export class EntityHydrator {
         const missingRelations: string[] = [];
         for (const relation of options.relations.slice().sort()) {
             if (typeof relation === 'string') {
-                const parts = !relation.startsWith('customFields') ? relation.split('.') : [relation];
+                const parts = relation.split('.');
                 let entity: Record<string, any> | undefined = target;
                 const path = [];
                 for (const part of parts) {
                     path.push(part);
+                    // null = the relation has been fetched but was null in the database.
+                    // undefined = the relation has not been fetched.
+                    if (entity && entity[part] === null) {
+                        break;
+                    }
                     if (entity && entity[part]) {
                         entity = Array.isArray(entity[part]) ? entity[part][0] : entity[part];
                     } else {

+ 4 - 4
packages/core/src/service/helpers/external-authentication/external-authentication.service.ts

@@ -94,10 +94,10 @@ export class ExternalAuthenticationService {
         config: {
             strategy: string;
             externalIdentifier: string;
-            verified: boolean;
             emailAddress: string;
-            firstName?: string;
-            lastName?: string;
+            firstName: string;
+            lastName: string;
+            verified?: boolean;
         },
     ): Promise<User> {
         let user: User;
@@ -206,7 +206,7 @@ export class ExternalAuthenticationService {
             }),
         );
 
-        return newUser;
+        return savedUser;
     }
 
     async findUser(

+ 142 - 93
packages/core/src/service/services/collection.service.ts

@@ -23,7 +23,12 @@ import { In, IsNull } from 'typeorm';
 
 import { RequestContext, SerializedRequestContext } from '../../api/common/request-context';
 import { RelationPaths } from '../../api/decorators/relations.decorator';
-import { ForbiddenError, IllegalOperationError, UserInputError } from '../../common/error/errors';
+import {
+    ForbiddenError,
+    IllegalOperationError,
+    InternalServerError,
+    UserInputError,
+} from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound, idsAreEqual } from '../../common/utils';
@@ -101,11 +106,14 @@ export class CollectionService implements OnModuleInit {
                     .createQueryBuilder('collection')
                     .select('collection.id', 'id')
                     .getRawMany();
-                await this.applyFiltersQueue.add({
-                    ctx: event.ctx.serialize(),
-                    collectionIds: collections.map(c => c.id),
-                },
-                {   ctx: event.ctx   });
+                await this.applyFiltersQueue.add(
+                    {
+                        ctx: event.ctx.serialize(),
+                        collectionIds: collections.map(c => c.id),
+                        applyToChangedVariantsOnly: true,
+                    },
+                    { ctx: event.ctx },
+                );
             });
 
         this.applyFiltersQueue = await this.jobQueueService.createQueue({
@@ -129,7 +137,7 @@ export class CollectionService implements OnModuleInit {
                         Logger.warn(`Could not find Collection with id ${collectionId}, skipping`);
                     }
                     completed++;
-                    if (collection) {
+                    if (collection !== undefined) {
                         let affectedVariantIds: ID[] = [];
                         try {
                             affectedVariantIds = await this.applyCollectionFiltersInternal(
@@ -147,8 +155,11 @@ export class CollectionService implements OnModuleInit {
                         }
                         job.setProgress(Math.ceil((completed / job.data.collectionIds.length) * 100));
                         if (affectedVariantIds.length) {
-                            await this.eventBus.publish(
-                                new CollectionModificationEvent(ctx, collection, affectedVariantIds),
+                            // To avoid performance issues on huge collections we first split the affected variant ids into chunks
+                            this.chunkArray(affectedVariantIds, 50000).map(chunk =>
+                                this.eventBus.publish(
+                                    new CollectionModificationEvent(ctx, collection as Collection, chunk),
+                                ),
                             );
                         }
                     }
@@ -469,11 +480,13 @@ export class CollectionService implements OnModuleInit {
             input,
             collection,
         );
-        await this.applyFiltersQueue.add({
-            ctx: ctx.serialize(),
-            collectionIds: [collection.id],
-        },
-        {   ctx   });
+        await this.applyFiltersQueue.add(
+            {
+                ctx: ctx.serialize(),
+                collectionIds: [collection.id],
+            },
+            { ctx },
+        );
         await this.eventBus.publish(new CollectionEvent(ctx, collectionWithRelations, 'created', input));
         return assertFound(this.findOne(ctx, collection.id));
     }
@@ -495,12 +508,14 @@ export class CollectionService implements OnModuleInit {
         });
         await this.customFieldRelationService.updateRelations(ctx, Collection, input, collection);
         if (input.filters) {
-            await this.applyFiltersQueue.add({
-                ctx: ctx.serialize(),
-                collectionIds: [collection.id],
-                applyToChangedVariantsOnly: false,
-            },
-            {   ctx   });
+            await this.applyFiltersQueue.add(
+                {
+                    ctx: ctx.serialize(),
+                    collectionIds: [collection.id],
+                    applyToChangedVariantsOnly: false,
+                },
+                { ctx },
+            );
         } else {
             const affectedVariantIds = await this.getCollectionProductVariantIds(collection);
             await this.eventBus.publish(new CollectionModificationEvent(ctx, collection, affectedVariantIds));
@@ -571,11 +586,13 @@ export class CollectionService implements OnModuleInit {
         siblings = moveToIndex(input.index, target, siblings);
 
         await this.connection.getRepository(ctx, Collection).save(siblings);
-        await this.applyFiltersQueue.add({
-            ctx: ctx.serialize(),
-            collectionIds: [target.id],
-        },
-        {   ctx   });
+        await this.applyFiltersQueue.add(
+            {
+                ctx: ctx.serialize(),
+                collectionIds: [target.id],
+            },
+            { ctx },
+        );
         return assertFound(this.findOne(ctx, input.collectionId));
     }
 
@@ -601,61 +618,117 @@ export class CollectionService implements OnModuleInit {
     };
 
     /**
-     * Applies the CollectionFilters
-     *
-     * If applyToChangedVariantsOnly (default: true) is true, then apply collection job will process only changed variants
-     * If applyToChangedVariantsOnly (default: true) is false, then apply collection job will process all variants
-     * This param is used when we update collection and collection filters are changed to update all
-     * variants (because other attributes of collection can be changed https://github.com/vendure-ecommerce/vendure/issues/1015)
+     * Applies the CollectionFilters and returns the IDs of ProductVariants that need to be added or removed.
      */
     private async applyCollectionFiltersInternal(
         collection: Collection,
         applyToChangedVariantsOnly = true,
     ): Promise<ID[]> {
+        const masterConnection = this.connection.rawConnection.createQueryRunner('master').connection;
         const ancestorFilters = await this.getAncestorFilters(collection);
-        const preIds = await this.getCollectionProductVariantIds(collection);
-        const filteredVariantIds = await this.getFilteredProductVariantIds([
-            ...ancestorFilters,
-            ...(collection.filters || []),
-        ]);
-        const postIds = filteredVariantIds.map(v => v.id);
-        const preIdsSet = new Set(preIds);
-        const postIdsSet = new Set(postIds);
+        const filters = [...ancestorFilters, ...(collection.filters || [])];
 
-        const toDeleteIds = preIds.filter(id => !postIdsSet.has(id));
-        const toAddIds = postIds.filter(id => !preIdsSet.has(id));
+        const { collectionFilters } = this.configService.catalogOptions;
 
-        try {
-            // First we remove variants that are no longer in the collection
-            const chunkedDeleteIds = this.chunkArray(toDeleteIds, 500);
+        // Create a basic query to retrieve the IDs of product variants that match the collection filters
+        let filteredQb = masterConnection
+            .getRepository(ProductVariant)
+            .createQueryBuilder('productVariant')
+            .select('productVariant.id', 'id')
+            .setFindOptions({ loadEagerRelations: false });
 
-            for (const chunkedDeleteId of chunkedDeleteIds) {
-                await this.connection.rawConnection
-                    .createQueryBuilder()
-                    .relation(Collection, 'productVariants')
-                    .of(collection)
-                    .remove(chunkedDeleteId);
+        // If there are no filters, we need to ensure that the query returns no results
+        if (filters.length === 0) {
+            filteredQb.andWhere('1 = 0');
+        }
+
+        //  Applies the CollectionFilters and returns an array of ProductVariant entities which match
+        for (const filterType of collectionFilters) {
+            const filtersOfType = filters.filter(f => f.code === filterType.code);
+            if (filtersOfType.length) {
+                for (const filter of filtersOfType) {
+                    filteredQb = filterType.apply(filteredQb, filter.args);
+                }
             }
+        }
 
-            // Then we add variants have been added
-            const chunkedAddIds = this.chunkArray(toAddIds, 500);
+        // Subquery for existing variants in the collection
+        const existingVariantsQb = masterConnection
+            .getRepository(ProductVariant)
+            .createQueryBuilder('variant')
+            .select('variant.id', 'id')
+            .setFindOptions({ loadEagerRelations: false })
+            .innerJoin('variant.collections', 'collection', 'collection.id = :id', { id: collection.id });
+
+        // Using CTE to find variants to add
+        const addQb = masterConnection
+            .createQueryBuilder()
+            .addCommonTableExpression(filteredQb, '_filtered_variants')
+            .addCommonTableExpression(existingVariantsQb, '_existing_variants')
+            .select('filtered_variants.id')
+            .from('_filtered_variants', 'filtered_variants')
+            .leftJoin(
+                '_existing_variants',
+                'existing_variants',
+                'filtered_variants.id = existing_variants.id',
+            )
+            .where('existing_variants.id IS NULL');
+
+        // Using CTE to find the variants to be deleted
+        const removeQb = masterConnection
+            .createQueryBuilder()
+            .addCommonTableExpression(filteredQb, '_filtered_variants')
+            .addCommonTableExpression(existingVariantsQb, '_existing_variants')
+            .select('existing_variants.id')
+            .from('_existing_variants', 'existing_variants')
+            .leftJoin(
+                '_filtered_variants',
+                'filtered_variants',
+                'existing_variants.id = filtered_variants.id',
+            )
+            .where('filtered_variants.id IS NULL')
+            .setParameters({ id: collection.id });
 
-            for (const chunkedAddId of chunkedAddIds) {
-                await this.connection.rawConnection
-                    .createQueryBuilder()
-                    .relation(Collection, 'productVariants')
-                    .of(collection)
-                    .add(chunkedAddId);
-            }
+        const [toAddIds, toRemoveIds] = await Promise.all([
+            addQb.getRawMany().then(results => results.map(result => result.id)),
+            removeQb.getRawMany().then(results => results.map(result => result.id)),
+        ]);
+
+        try {
+            await this.connection.rawConnection.transaction(async transactionalEntityManager => {
+                const chunkedDeleteIds = this.chunkArray(toRemoveIds, 5000);
+                const chunkedAddIds = this.chunkArray(toAddIds, 5000);
+                await Promise.all([
+                    // Delete variants that should no longer be in the collection
+                    ...chunkedDeleteIds.map(chunk =>
+                        transactionalEntityManager
+                            .createQueryBuilder()
+                            .relation(Collection, 'productVariants')
+                            .of(collection)
+                            .remove(chunk),
+                    ),
+                    // Adding options that should be in the collection
+                    ...chunkedAddIds.map(chunk =>
+                        transactionalEntityManager
+                            .createQueryBuilder()
+                            .relation(Collection, 'productVariants')
+                            .of(collection)
+                            .add(chunk),
+                    ),
+                ]);
+            });
         } catch (e: any) {
             Logger.error(e);
         }
 
         if (applyToChangedVariantsOnly) {
-            return [...preIds.filter(id => !postIdsSet.has(id)), ...postIds.filter(id => !preIdsSet.has(id))];
-        } else {
-            return [...preIds.filter(id => !postIdsSet.has(id)), ...postIds];
+            return [...toAddIds, ...toRemoveIds];
         }
+
+        return [
+            ...(await existingVariantsQb.getRawMany().then(results => results.map(result => result.id))),
+            ...toRemoveIds,
+        ];
     }
 
     /**
@@ -676,32 +749,6 @@ export class CollectionService implements OnModuleInit {
         return ancestorFilters;
     }
 
-    /**
-     * Applies the CollectionFilters and returns an array of ProductVariant entities which match.
-     */
-    private async getFilteredProductVariantIds(filters: ConfigurableOperation[]): Promise<Array<{ id: ID }>> {
-        if (filters.length === 0) {
-            return [];
-        }
-        const { collectionFilters } = this.configService.catalogOptions;
-        let qb = this.connection.rawConnection
-            .getRepository(ProductVariant)
-            .createQueryBuilder('productVariant');
-
-        for (const filterType of collectionFilters) {
-            const filtersOfType = filters.filter(f => f.code === filterType.code);
-            if (filtersOfType.length) {
-                for (const filter of filtersOfType) {
-                    qb = filterType.apply(qb, filter.args);
-                }
-            }
-        }
-
-        // This is the most performant (time & memory) way to get
-        // just the variant IDs, which is all we need.
-        return qb.select('productVariant.id', 'id').getRawMany();
-    }
-
     /**
      * Returns the IDs of the Collection's ProductVariants.
      */
@@ -830,11 +877,13 @@ export class CollectionService implements OnModuleInit {
         );
         await this.assetService.assignToChannel(ctx, { channelId: input.channelId, assetIds });
 
-        await this.applyFiltersQueue.add({
-            ctx: ctx.serialize(),
-            collectionIds: collectionsToAssign.map(collection => collection.id),
-        },
-        {   ctx   });
+        await this.applyFiltersQueue.add(
+            {
+                ctx: ctx.serialize(),
+                collectionIds: collectionsToAssign.map(collection => collection.id),
+            },
+            { ctx },
+        );
 
         return this.connection
             .findByIdsInChannel(

+ 1 - 0
packages/core/src/service/services/stock-movement.service.ts

@@ -214,6 +214,7 @@ export class StockMovementService {
                 ctx,
                 ProductVariant,
                 orderLine.productVariantId,
+                { includeSoftDeleted: true },
             );
             const saleLocations = await this.stockLocationService.getSaleLocations(
                 ctx,

+ 0 - 2
packages/create/package.json

@@ -24,7 +24,6 @@
     },
     "devDependencies": {
         "@types/cross-spawn": "^6.0.6",
-        "@types/detect-port": "^1.3.5",
         "@types/fs-extra": "^11.0.4",
         "@types/handlebars": "^4.1.0",
         "@types/semver": "^7.5.8",
@@ -38,7 +37,6 @@
         "@vendure/common": "^3.0.0",
         "commander": "^11.0.0",
         "cross-spawn": "^7.0.3",
-        "detect-port": "^1.5.1",
         "fs-extra": "^11.2.0",
         "handlebars": "^4.7.8",
         "picocolors": "^1.0.0",

+ 20 - 7
packages/create/src/create-vendure-app.ts

@@ -1,7 +1,6 @@
 /* eslint-disable no-console */
 import { intro, note, outro, select, spinner } from '@clack/prompts';
 import { program } from 'commander';
-import detectPort from 'detect-port';
 import fs from 'fs-extra';
 import os from 'os';
 import path from 'path';
@@ -20,7 +19,7 @@ import {
     scaffoldAlreadyExists,
     yarnIsAvailable,
 } from './helpers';
-import { CliLogLevel, DbType, PackageManager } from './types';
+import { CliLogLevel, PackageManager } from './types';
 
 // eslint-disable-next-line @typescript-eslint/no-var-requires
 const packageJson = require('../package.json');
@@ -63,15 +62,30 @@ export async function createVendureApp(
     if (!runPreChecks(name, useNpm)) {
         return;
     }
-    if (await isServerPortInUse()) {
-        console.log(pc.red(`Port ${SERVER_PORT} is in use. Please make it available and then re-try.`));
-        process.exit(1);
-    }
 
     intro(
         `Let's create a ${pc.blue(pc.bold('Vendure App'))} ✨ ${pc.dim(`v${packageJson.version as string}`)}`,
     );
 
+    const portSpinner = spinner();
+    let port = SERVER_PORT;
+    const attemptedPortRange = 20;
+    portSpinner.start(`Establishing port...`);
+    while (await isServerPortInUse(port)) {
+        const nextPort = port + 1;
+        portSpinner.message(pc.yellow(`Port ${port} is in use. Attempting to use ${nextPort}`));
+        port = nextPort;
+        if (port > SERVER_PORT + attemptedPortRange) {
+            portSpinner.stop(pc.red('Could not find an available port'));
+            outro(
+                `Please ensure there is a port available between ${SERVER_PORT} and ${SERVER_PORT + attemptedPortRange}`,
+            );
+            process.exit(1);
+        }
+    }
+    portSpinner.stop(`Using port ${port}`);
+    process.env.PORT = port.toString();
+
     const root = path.resolve(name);
     const appName = path.basename(root);
     const scaffoldExists = scaffoldAlreadyExists(root, name);
@@ -211,7 +225,6 @@ export async function createVendureApp(
         const assetsDir = path.join(__dirname, '../assets');
 
         const initialDataPath = path.join(assetsDir, 'initial-data.json');
-        const port = await detectPort(3000);
         const vendureLogLevel =
             logLevel === 'silent'
                 ? LogLevel.Error

+ 1 - 1
packages/create/src/gather-user-responses.ts

@@ -77,7 +77,7 @@ export async function gatherUserResponses(
         dbType === 'postgres'
             ? await select({
                   message:
-                      'Use SSL to connect to the database? (only enable if you database provider supports SSL)',
+                      'Use SSL to connect to the database? (only enable if your database provider supports SSL)',
                   options: [
                       { label: 'no', value: false },
                       { label: 'yes', value: true },

+ 3 - 3
packages/create/src/helpers.ts

@@ -414,13 +414,13 @@ function throwDatabaseSchemaDoesNotExist(dbName: string, schemaName: string) {
     );
 }
 
-export function isServerPortInUse(): Promise<boolean> {
+export function isServerPortInUse(port: number): Promise<boolean> {
     // eslint-disable-next-line @typescript-eslint/no-var-requires
     const tcpPortUsed = require('tcp-port-used');
     try {
-        return tcpPortUsed.check(SERVER_PORT);
+        return tcpPortUsed.check(port);
     } catch (e: any) {
-        console.log(pc.yellow(`Warning: could not determine whether port ${SERVER_PORT} is available`));
+        console.log(pc.yellow(`Warning: could not determine whether port ${port} is available`));
         return Promise.resolve(false);
     }
 }

+ 1 - 0
packages/create/templates/.env.hbs

@@ -1,4 +1,5 @@
 APP_ENV=dev
+PORT=3000
 COOKIE_SECRET={{ cookieSecret }}
 SUPERADMIN_USERNAME={{{ escapeSingle superadminIdentifier }}}
 SUPERADMIN_PASSWORD={{{ escapeSingle superadminPassword }}}

+ 1 - 0
packages/create/templates/environment.d.hbs

@@ -6,6 +6,7 @@ declare global {
     namespace NodeJS {
         interface ProcessEnv {
             APP_ENV: string;
+            PORT: string;
             COOKIE_SECRET: string;
             SUPERADMIN_USERNAME: string;
             SUPERADMIN_PASSWORD: string;

+ 6 - 5
packages/create/templates/vendure-config.hbs

@@ -4,17 +4,18 @@ import {
     DefaultSearchPlugin,
     VendureConfig,
 } from '@vendure/core';
-import { defaultEmailHandlers, EmailPlugin } from '@vendure/email-plugin';
+import { defaultEmailHandlers, EmailPlugin, FileBasedTemplateLoader } from '@vendure/email-plugin';
 import { AssetServerPlugin } from '@vendure/asset-server-plugin';
 import { AdminUiPlugin } from '@vendure/admin-ui-plugin';
 import 'dotenv/config';
 import path from 'path';
 
 const IS_DEV = process.env.APP_ENV === 'dev';
+const serverPort = +process.env.PORT || 3000;
 
 export const config: VendureConfig = {
     apiOptions: {
-        port: 3000,
+        port: serverPort,
         adminApiPath: 'admin-api',
         shopApiPath: 'shop-api',
         // The following options are useful in development mode,
@@ -88,7 +89,7 @@ export const config: VendureConfig = {
             outputPath: path.join(__dirname, '../static/email/test-emails'),
             route: 'mailbox',
             handlers: defaultEmailHandlers,
-            templatePath: path.join(__dirname, '../static/email/templates'),
+            templateLoader: new FileBasedTemplateLoader(path.join(__dirname, '../static/email/templates')),
             globalTemplateVars: {
                 // The following variables will change depending on your storefront implementation.
                 // Here we are assuming a storefront running at http://localhost:8080.
@@ -100,9 +101,9 @@ export const config: VendureConfig = {
         }),
         AdminUiPlugin.init({
             route: 'admin',
-            port: 3002,
+            port: serverPort + 2,
             adminUiConfig: {
-                apiPort: 3000,
+                apiPort: serverPort,
             },
         }),
     ],

+ 2 - 2
packages/dev-server/dev-config.ts

@@ -13,7 +13,7 @@ import {
     VendureConfig,
 } from '@vendure/core';
 import { ElasticsearchPlugin } from '@vendure/elasticsearch-plugin';
-import { defaultEmailHandlers, EmailPlugin } from '@vendure/email-plugin';
+import { defaultEmailHandlers, EmailPlugin, FileBasedTemplateLoader } from '@vendure/email-plugin';
 import { BullMQJobQueuePlugin } from '@vendure/job-queue-plugin/package/bullmq';
 import 'dotenv/config';
 import { compileUiExtensions } from '@vendure/ui-devkit/compiler';
@@ -90,7 +90,7 @@ export const devConfig: VendureConfig = {
             devMode: true,
             route: 'mailbox',
             handlers: defaultEmailHandlers,
-            templatePath: path.join(__dirname, '../email-plugin/templates'),
+            templateLoader: new FileBasedTemplateLoader(path.join(__dirname, '../email-plugin/templates')),
             outputPath: path.join(__dirname, 'test-emails'),
             globalTemplateVars: {
                 verifyEmailAddressUrl: 'http://localhost:4201/verify',

+ 0 - 1
packages/email-plugin/src/handler/event-handler.ts

@@ -125,7 +125,6 @@ import {
  *   plugins: [
  *     EmailPlugin.init({
  *       handler: [...defaultEmailHandlers, quoteRequestedHandler],
- *       templatePath: path.join(__dirname, 'vendure/email/templates'),
  *       // ... etc
  *     }),
  *   ],

+ 77 - 68
packages/email-plugin/src/plugin.spec.ts

@@ -3,13 +3,11 @@ import { Test, TestingModule } from '@nestjs/testing';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
 import {
-    DefaultLogger,
     EventBus,
     Injector,
     JobQueueService,
     LanguageCode,
     Logger,
-    LogLevel,
     Order,
     OrderStateTransitionEvent,
     PluginCommonModule,
@@ -23,15 +21,14 @@ import path from 'path';
 import { Readable } from 'stream';
 import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
 
-import { orderConfirmationHandler } from './handler/default-email-handlers';
 import { EmailProcessor } from './email-processor';
-import { EmailSender } from './sender/email-sender';
-import { EmailEventHandler } from './handler/event-handler';
 import { EmailEventListener } from './event-listener';
+import { orderConfirmationHandler } from './handler/default-email-handlers';
+import { EmailEventHandler } from './handler/event-handler';
 import { EmailPlugin } from './plugin';
-import { EmailDetails, EmailPluginOptions, EmailTransportOptions, LoadTemplateInput } from './types';
-import { TemplateLoader } from './template-loader/template-loader';
-import fs from 'fs-extra';
+import { EmailSender } from './sender/email-sender';
+import { FileBasedTemplateLoader } from './template-loader/file-based-template-loader';
+import { EmailDetails, EmailPluginOptions, EmailTransportOptions } from './types';
 
 describe('EmailPlugin', () => {
     let eventBus: EventBus;
@@ -54,7 +51,7 @@ describe('EmailPlugin', () => {
                 }),
                 PluginCommonModule,
                 EmailPlugin.init({
-                    templatePath: path.join(__dirname, '../test-templates'),
+                    templateLoader: new FileBasedTemplateLoader(path.join(__dirname, '../test-templates')),
                     transport: {
                         type: 'testing',
                         onSend,
@@ -95,7 +92,7 @@ describe('EmailPlugin', () => {
 
         await initPluginWithHandlers([handler]);
 
-        eventBus.publish(new MockEvent(ctx, true));
+        await eventBus.publish(new MockEvent(ctx, true));
         await pause();
         expect(onSend.mock.calls[0][0].subject).toBe('Hello');
         expect(onSend.mock.calls[0][0].recipient).toBe('test@test.com');
@@ -118,11 +115,11 @@ describe('EmailPlugin', () => {
 
             await initPluginWithHandlers([handler]);
 
-            eventBus.publish(new MockEvent(ctx, false));
+            await eventBus.publish(new MockEvent(ctx, false));
             await pause();
             expect(onSend).not.toHaveBeenCalled();
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(onSend).toHaveBeenCalledTimes(1);
         });
@@ -138,13 +135,13 @@ describe('EmailPlugin', () => {
 
             await initPluginWithHandlers([handler]);
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(onSend).not.toHaveBeenCalled();
 
             const ctxWithUser = RequestContext.deserialize({ ...ctx, _session: { user: { id: 42 } } } as any);
 
-            eventBus.publish(new MockEvent(ctxWithUser, true));
+            await eventBus.publish(new MockEvent(ctxWithUser, true));
             await pause();
             expect(onSend).toHaveBeenCalledTimes(1);
         });
@@ -160,11 +157,11 @@ describe('EmailPlugin', () => {
 
             await initPluginWithHandlers([handler]);
 
-            eventBus.publish(new MockEvent(ctx, false));
+            await eventBus.publish(new MockEvent(ctx, false));
             await pause();
             expect(onSend).not.toHaveBeenCalled();
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(onSend).toHaveBeenCalledTimes(1);
         });
@@ -186,7 +183,7 @@ describe('EmailPlugin', () => {
 
             await initPluginWithHandlers([handler]);
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(onSend.mock.calls[0][0].subject).toBe('Hello foo');
         });
@@ -201,7 +198,7 @@ describe('EmailPlugin', () => {
 
             await initPluginWithHandlers([handler]);
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(onSend.mock.calls[0][0].body).toContain('this is the test var');
         });
@@ -221,7 +218,7 @@ describe('EmailPlugin', () => {
 
             await initPluginWithHandlers([handler]);
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(onSend.mock.calls[0][0].body).toContain('Total: 123');
         });
@@ -237,7 +234,7 @@ describe('EmailPlugin', () => {
                 globalTemplateVars: { globalVar: 'baz' },
             });
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(onSend.mock.calls[0][0].subject).toBe('Hello baz');
         });
@@ -268,7 +265,7 @@ describe('EmailPlugin', () => {
                 },
             });
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(onSend.mock.calls[0][0].subject).toBe(`Job hello-service, blue`);
         });
@@ -284,7 +281,7 @@ describe('EmailPlugin', () => {
                 globalTemplateVars: { globalVar: 'baz' },
             });
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(onSend.mock.calls[0][0].from).toBe('"test from baz" <noreply@test.com>');
         });
@@ -301,7 +298,7 @@ describe('EmailPlugin', () => {
                 globalTemplateVars: { globalFrom: 'Test <test@test.com>' },
             });
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(onSend.mock.calls[0][0].from).toBe('Test <test@test.com>');
         });
@@ -318,7 +315,7 @@ describe('EmailPlugin', () => {
                 globalTemplateVars: { globalVar: 'baz' },
             });
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(onSend.mock.calls[0][0].subject).toBe('Hello baz quux');
         });
@@ -333,7 +330,7 @@ describe('EmailPlugin', () => {
 
             await initPluginWithHandlers([handler], { globalTemplateVars: { name: 'baz' } });
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(onSend.mock.calls[0][0].subject).toBe('Hello quux');
         });
@@ -355,7 +352,7 @@ describe('EmailPlugin', () => {
 
             await initPluginWithHandlers([handler]);
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(onSend.mock.calls[0][0].body).toContain('Date: Wed Jan 01 2020 10:00:00');
         });
@@ -370,7 +367,7 @@ describe('EmailPlugin', () => {
 
             await initPluginWithHandlers([handler]);
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(onSend.mock.calls[0][0].body).toContain('Price: 1.23');
             expect(onSend.mock.calls[0][0].body).toContain('Price: €1.23');
@@ -401,13 +398,13 @@ describe('EmailPlugin', () => {
             await initPluginWithHandlers([handler]);
 
             const ctxTa = RequestContext.deserialize({ ...ctx, _languageCode: LanguageCode.ta } as any);
-            eventBus.publish(new MockEvent(ctxTa, true));
+            await eventBus.publish(new MockEvent(ctxTa, true));
             await pause();
             expect(onSend.mock.calls[0][0].subject).toBe('Hello, Test!');
             expect(onSend.mock.calls[0][0].body).toContain('Default body.');
 
             const ctxDe = RequestContext.deserialize({ ...ctx, _languageCode: LanguageCode.de } as any);
-            eventBus.publish(new MockEvent(ctxDe, true));
+            await eventBus.publish(new MockEvent(ctxDe, true));
             await pause();
             expect(onSend.mock.calls[1][0].subject).toBe('Servus, Test!');
             expect(onSend.mock.calls[1][0].body).toContain('German body.');
@@ -429,7 +426,7 @@ describe('EmailPlugin', () => {
                 });
 
             const ctxEn = RequestContext.deserialize({ ...ctx, _languageCode: LanguageCode.en } as any);
-            eventBus.publish(new MockEvent(ctxEn, true));
+            await eventBus.publish(new MockEvent(ctxEn, true));
             await pause();
             expect(onSend.mock.calls[1][0].subject).toBe('Servus, Test!');
             expect(onSend.mock.calls[1][0].body).toContain('German body.');
@@ -451,7 +448,7 @@ describe('EmailPlugin', () => {
 
             await initPluginWithHandlers([handler]);
 
-            eventBus.publish(
+            await eventBus.publish(
                 new MockEvent(
                     RequestContext.deserialize({
                         _channel: { code: DEFAULT_CHANNEL_CODE },
@@ -479,7 +476,7 @@ describe('EmailPlugin', () => {
 
             await initPluginWithHandlers([handler]);
 
-            eventBus.publish(
+            await eventBus.publish(
                 new MockEvent(
                     RequestContext.deserialize({
                         _channel: { code: DEFAULT_CHANNEL_CODE },
@@ -506,8 +503,8 @@ describe('EmailPlugin', () => {
 
             await initPluginWithHandlers([handler]);
 
-            eventBus.publish(new MockEvent(RequestContext.empty(), false));
-            eventBus.publish(new MockEvent(RequestContext.empty(), true));
+            await eventBus.publish(new MockEvent(RequestContext.empty(), false));
+            await eventBus.publish(new MockEvent(RequestContext.empty(), true));
             await pause();
 
             expect(callCount).toBe(1);
@@ -530,7 +527,7 @@ describe('EmailPlugin', () => {
 
             await initPluginWithHandlers([handler]);
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(onSend.mock.calls[0][0].attachments).toEqual([]);
         });
@@ -548,7 +545,7 @@ describe('EmailPlugin', () => {
                 ]);
 
             await initPluginWithHandlers([handler]);
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
 
             expect(onSend.mock.calls[0][0].attachments).toEqual([{ path: TEST_IMAGE_PATH }]);
@@ -567,7 +564,7 @@ describe('EmailPlugin', () => {
                 ]);
 
             await initPluginWithHandlers([handler]);
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
 
             expect(onSend.mock.calls[0][0].attachments).toEqual([{ path: TEST_IMAGE_PATH }]);
@@ -586,7 +583,7 @@ describe('EmailPlugin', () => {
                 ]);
 
             await initPluginWithHandlers([handler]);
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
 
             const attachment = onSend.mock.calls[0][0].attachments[0].content;
@@ -607,7 +604,7 @@ describe('EmailPlugin', () => {
                 ]);
 
             await initPluginWithHandlers([handler]);
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
 
             const attachment = onSend.mock.calls[0][0].attachments[0].content;
@@ -627,7 +624,7 @@ describe('EmailPlugin', () => {
                 ]);
 
             await initPluginWithHandlers([handler]);
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
 
             const attachment = onSend.mock.calls[0][0].attachments[0].content;
@@ -647,7 +644,7 @@ describe('EmailPlugin', () => {
                 ]);
 
             await initPluginWithHandlers([handler]);
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
 
             const attachment = onSend.mock.calls[0][0].attachments[0].content;
@@ -669,7 +666,7 @@ describe('EmailPlugin', () => {
                 ]);
 
             await initPluginWithHandlers([handler]);
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
 
             const attachment = onSend.mock.calls[0][0].attachments[0].content;
@@ -691,7 +688,7 @@ describe('EmailPlugin', () => {
                 ]);
 
             await initPluginWithHandlers([handler]);
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
 
             expect(testingLogger.warnSpy.mock.calls[0][0]).toContain(
@@ -703,7 +700,7 @@ describe('EmailPlugin', () => {
     describe('orderConfirmationHandler', () => {
         beforeEach(async () => {
             await initPluginWithHandlers([orderConfirmationHandler], {
-                templatePath: path.join(__dirname, '../templates'),
+                templateLoader: new FileBasedTemplateLoader(path.join(__dirname, '../templates')),
             });
         });
 
@@ -721,32 +718,42 @@ describe('EmailPlugin', () => {
         } as any;
 
         it('filters events with wrong order state', async () => {
-            eventBus.publish(new OrderStateTransitionEvent('AddingItems', 'ArrangingPayment', ctx, order));
+            await eventBus.publish(
+                new OrderStateTransitionEvent('AddingItems', 'ArrangingPayment', ctx, order),
+            );
             await pause();
             expect(onSend).not.toHaveBeenCalled();
 
-            eventBus.publish(new OrderStateTransitionEvent('AddingItems', 'Cancelled', ctx, order));
+            await eventBus.publish(new OrderStateTransitionEvent('AddingItems', 'Cancelled', ctx, order));
             await pause();
             expect(onSend).not.toHaveBeenCalled();
 
-            eventBus.publish(new OrderStateTransitionEvent('AddingItems', 'PaymentAuthorized', ctx, order));
+            await eventBus.publish(
+                new OrderStateTransitionEvent('AddingItems', 'PaymentAuthorized', ctx, order),
+            );
             await pause();
             expect(onSend).not.toHaveBeenCalled();
 
-            eventBus.publish(new OrderStateTransitionEvent('ArrangingPayment', 'PaymentSettled', ctx, order));
+            await eventBus.publish(
+                new OrderStateTransitionEvent('ArrangingPayment', 'PaymentSettled', ctx, order),
+            );
             await pause();
             expect(onSend).toHaveBeenCalledTimes(1);
         });
 
         it('sets the Order Customer emailAddress as recipient', async () => {
-            eventBus.publish(new OrderStateTransitionEvent('ArrangingPayment', 'PaymentSettled', ctx, order));
+            await eventBus.publish(
+                new OrderStateTransitionEvent('ArrangingPayment', 'PaymentSettled', ctx, order),
+            );
             await pause();
 
             expect(onSend.mock.calls[0][0].recipient).toBe(order.customer!.emailAddress);
         });
 
         it('sets the subject', async () => {
-            eventBus.publish(new OrderStateTransitionEvent('ArrangingPayment', 'PaymentSettled', ctx, order));
+            await eventBus.publish(
+                new OrderStateTransitionEvent('ArrangingPayment', 'PaymentSettled', ctx, order),
+            );
             await pause();
 
             expect(onSend.mock.calls[0][0].subject).toBe(`Order confirmation for #${order.code as string}`);
@@ -769,7 +776,7 @@ describe('EmailPlugin', () => {
 
             await initPluginWithHandlers([handler]);
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(testingLogger.errorSpy.mock.calls[0][0]).toContain('ENOENT: no such file or directory');
         });
@@ -789,7 +796,7 @@ describe('EmailPlugin', () => {
 
             await initPluginWithHandlers([handler]);
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(testingLogger.errorSpy.mock.calls[0][0]).toContain('Parse error on line 3:');
         });
@@ -812,7 +819,7 @@ describe('EmailPlugin', () => {
 
             await initPluginWithHandlers([handler]);
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(testingLogger.errorSpy.mock.calls[0][0]).toContain('something went horribly wrong!');
         });
@@ -839,7 +846,7 @@ describe('EmailPlugin', () => {
                 emailSender: fakeSender,
             });
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(send.mock.calls[0][0].subject).toBe('Hello');
             expect(send.mock.calls[0][0].recipient).toBe('test@test.com');
@@ -863,7 +870,7 @@ describe('EmailPlugin', () => {
                 .setOptionalAddressFields(() => ({ cc: 'foo@bar.com' }));
 
             await initPluginWithHandlers([handler]);
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
 
             expect(onSend.mock.calls[0][0].cc).toBe('foo@bar.com');
@@ -878,7 +885,7 @@ describe('EmailPlugin', () => {
                 .setOptionalAddressFields(() => ({ bcc: 'foo@bar.com' }));
 
             await initPluginWithHandlers([handler]);
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
 
             expect(onSend.mock.calls[0][0].bcc).toBe('foo@bar.com');
@@ -893,7 +900,7 @@ describe('EmailPlugin', () => {
                 .setOptionalAddressFields(() => ({ replyTo: 'foo@bar.com' }));
 
             await initPluginWithHandlers([handler]);
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
 
             expect(onSend.mock.calls[0][0].replyTo).toBe('foo@bar.com');
@@ -912,12 +919,14 @@ describe('EmailPlugin', () => {
                 .setSubject('Hello')
                 .setTemplateVars(event => ({ subjectVar: 'foo' }));
             module = await initPluginWithHandlers([handler], {
-                transport: async (injector, ctx) => {
+                transport: async (injector, _ctx) => {
                     injectorArg = injector;
-                    ctxArg = ctx;
+                    ctxArg = _ctx;
                     return {
                         type: 'testing',
-                        onSend: () => {},
+                        onSend: () => {
+                            /* */
+                        },
                     };
                 },
             });
@@ -925,7 +934,7 @@ describe('EmailPlugin', () => {
                 _channel: { code: DEFAULT_CHANNEL_CODE },
                 _languageCode: LanguageCode.en,
             } as any);
-            module!.get(EventBus).publish(new MockEvent(ctx, true));
+            await module!.get(EventBus).publish(new MockEvent(ctx, true));
             await pause();
             expect(module).toBeDefined();
             expect(typeof module.get(EmailPlugin).options.transport).toBe('function');
@@ -936,7 +945,7 @@ describe('EmailPlugin', () => {
                 _channel: { code: DEFAULT_CHANNEL_CODE },
                 _languageCode: LanguageCode.en,
             } as any);
-            module!.get(EventBus).publish(new MockEvent(ctx, true));
+            await module!.get(EventBus).publish(new MockEvent(ctx, true));
             await pause();
             expect(injectorArg?.constructor.name).toBe('Injector');
             expect(ctxArg?.constructor.name).toBe('RequestContext');
@@ -963,7 +972,7 @@ describe('EmailPlugin', () => {
 
             await initPluginWithHandlers([handler]);
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(onSend.mock.calls[0][0].subject).toBe('Hello');
             expect(onSend.mock.calls[0][0].recipient).toBe('test@test.com');
@@ -979,21 +988,21 @@ describe('EmailPlugin', () => {
                 .setFrom('"test from" <noreply@test.com>')
                 .setRecipient(() => 'test@test.com')
                 .setSubject(async (_e, _ctx, _i) => {
-                    const service = _i.get(MockService)
-                    const mockData = await service.someAsyncMethod()
+                    const service = _i.get(MockService);
+                    const mockData = await service.someAsyncMethod();
                     return `Hello from ${mockData} and {{ subjectVar }}`;
                 })
                 .setTemplateVars(event => ({ subjectVar: 'foo' }));
 
             await initPluginWithHandlers([handler]);
 
-            eventBus.publish(new MockEvent(ctx, true));
+            await eventBus.publish(new MockEvent(ctx, true));
             await pause();
             expect(onSend.mock.calls[0][0].subject).toBe('Hello from loaded data and foo');
             expect(onSend.mock.calls[0][0].recipient).toBe('test@test.com');
             expect(onSend.mock.calls[0][0].from).toBe('"test from" <noreply@test.com>');
         });
-    })
+    });
 });
 
 class FakeCustomSender implements EmailSender {
@@ -1015,4 +1024,4 @@ class MockService {
     someAsyncMethod() {
         return Promise.resolve('loaded data');
     }
-}
+}

+ 5 - 5
packages/email-plugin/src/plugin.ts

@@ -60,14 +60,14 @@ import {
  *
  * @example
  * ```ts
- * import { defaultEmailHandlers, EmailPlugin } from '\@vendure/email-plugin';
+ * import { defaultEmailHandlers, EmailPlugin, FileBasedTemplateLoader } from '\@vendure/email-plugin';
  *
  * const config: VendureConfig = {
  *   // Add an instance of the plugin to the plugins array
  *   plugins: [
  *     EmailPlugin.init({
  *       handler: defaultEmailHandlers,
- *       templatePath: path.join(__dirname, 'static/email/templates'),
+ *       templateLoader: new FileBasedTemplateLoader(path.join(__dirname, '../static/email/templates')),
  *       transport: {
  *         type: 'smtp',
  *         host: 'smtp.example.com',
@@ -226,13 +226,13 @@ import {
  *
  * @example
  * ```ts
- * import { defaultEmailHandlers, EmailPlugin } from '\@vendure/email-plugin';
+ * import { defaultEmailHandlers, EmailPlugin, FileBasedTemplateLoader } from '\@vendure/email-plugin';
  * import { MyTransportService } from './transport.services.ts';
  * const config: VendureConfig = {
  *   plugins: [
  *     EmailPlugin.init({
  *       handler: defaultEmailHandlers,
- *       templatePath: path.join(__dirname, 'static/email/templates'),
+ *       templateLoader: new FileBasedTemplateLoader(path.join(__dirname, '../static/email/templates')),
  *       transport: (injector, ctx) => {
  *         if (ctx) {
  *           return injector.get(MyTransportService).getSettings(ctx);
@@ -260,7 +260,7 @@ import {
  *   devMode: true,
  *   route: 'mailbox',
  *   handler: defaultEmailHandlers,
- *   templatePath: path.join(__dirname, 'vendure/email/templates'),
+ *   templateLoader: new FileBasedTemplateLoader(path.join(__dirname, '../static/email/templates')),
  *   outputPath: path.join(__dirname, 'test-emails'),
  * })
  * ```

+ 22 - 1
packages/email-plugin/src/types.ts

@@ -219,7 +219,7 @@ export interface SMTPTransportOptions extends SMTPTransport.Options {
  *   plugins: [
  *     EmailPlugin.init({
  *       handler: defaultEmailHandlers,
- *       templatePath: path.join(__dirname, 'static/email/templates'),
+ *       templateLoader: new FileBasedTemplateLoader(path.join(__dirname, '../static/email/templates')),
  *       transport: {
  *         type: 'ses',
  *         SES: { ses, aws: { SendRawEmailCommand } },
@@ -392,9 +392,30 @@ export interface EmailTemplateConfig {
     subject: string;
 }
 
+/**
+ * @description
+ * The object passed to the {@link TemplateLoader} `loadTemplate()` method.
+ *
+ * @docsCategory core plugins/EmailPlugin
+ * @docsPage Email Plugin Types
+ */
 export interface LoadTemplateInput {
+    /**
+     * @description
+     * The type corresponds to the string passed to the EmailEventListener constructor.
+     */
     type: string;
+    /**
+     * @description
+     * The template name is specified by the EmailEventHander's call to
+     * the `addTemplate()` method, and will default to `body.hbs`
+     */
     templateName: string;
+    /**
+     * @description
+     * The variables defined by the globalTemplateVars as well as any variables defined in the
+     * EmailEventHandler's `setTemplateVars()` method.
+     */
     templateVars: any;
 }
 

+ 1 - 0
packages/payments-plugin/src/mollie/mollie.service.ts

@@ -178,6 +178,7 @@ export class MollieService {
                 authorizedAsOwnerOnly: false,
                 channel: ctx.channel,
                 languageCode: ctx.languageCode,
+                req: ctx.req,
             });
             await this.addPayment(
                 adminCtx,