Sfoglia il codice sorgente

Merge branch 'master' into minor

David Höck 7 mesi fa
parent
commit
3d06dda7aa
25 ha cambiato i file con 746 aggiunte e 140 eliminazioni
  1. 4 0
      .github/workflows/publish_master_to_npm.yml
  2. 56 0
      .github/workflows/stale_bot.yml
  3. 467 0
      docs/docs/guides/developer-guide/custom-strategies-in-plugins/index.mdx
  4. 37 30
      docs/docs/guides/getting-started/installation/index.md
  5. 3 4
      docs/sidebars.js
  6. 29 2
      packages/cli/src/commands/migrate/load-vendure-config-file.ts
  7. 1 1
      packages/core/e2e/shipping-method.e2e-spec.ts
  8. 1 1
      packages/core/src/config/shipping-method/default-shipping-calculator.ts
  9. 2 9
      packages/core/src/config/tax/address-based-tax-zone-strategy.spec.ts
  10. 3 2
      packages/core/src/config/tax/address-based-tax-zone-strategy.ts
  11. 4 2
      packages/dashboard/src/app/main.tsx
  12. 6 35
      packages/dashboard/src/app/routes/_authenticated/_product-variants/product-variants.tsx
  13. 13 36
      packages/dashboard/src/app/routes/_authenticated/_products/components/product-variants-table.tsx
  14. 5 0
      packages/dashboard/src/app/routes/_authenticated/_products/products.graphql.ts
  15. 7 2
      packages/dashboard/src/lib/components/shared/detail-page-button.tsx
  16. 13 5
      packages/dashboard/src/lib/components/shared/paginated-list-data-table.tsx
  17. 24 0
      packages/dashboard/src/lib/components/shared/stock-level-label.tsx
  18. 24 0
      packages/dashboard/src/lib/framework/defaults.ts
  19. 1 9
      packages/dashboard/src/lib/framework/page/list-page.tsx
  20. 1 0
      packages/dashboard/src/lib/index.ts
  21. 1 1
      packages/dashboard/vite/vite-plugin-config.ts
  22. 1 1
      packages/dashboard/vite/vite-plugin-dashboard-metadata.ts
  23. 40 0
      packages/dashboard/vite/vite-plugin-transform-index.ts
  24. 2 0
      packages/dashboard/vite/vite-plugin-vendure-dashboard.ts
  25. 1 0
      packages/dev-server/vite.config.mts

+ 4 - 0
.github/workflows/publish_master_to_npm.yml

@@ -5,6 +5,10 @@ on:
     push:
         branches:
             - master
+        paths:
+            - 'packages/**'
+            - 'package.json'
+            - 'package-lock.json'
 
 jobs:
     publish:

+ 56 - 0
.github/workflows/stale_bot.yml

@@ -0,0 +1,56 @@
+name: Close label-gated stale issues
+
+on:
+    schedule:
+        - cron: '0 2 * * *' # 02:00 UTC daily
+    workflow_dispatch: # allow manual runs
+
+permissions:
+    issues: write
+    pull-requests: write
+
+jobs:
+    sweep:
+        runs-on: ubuntu-latest
+        steps:
+            - uses: actions/stale@v9 # v9 or later
+              with:
+                  days-before-stale: 30
+                  days-before-close: 3
+                  # Temporary fix to avoid touching PRs at all
+                  only-pr-labels: 'no-response'
+                  # Act ONLY on issues that *still* have one of these labels
+                  any-of-issue-labels: |
+                      status: missing info ❓,
+                      status: reproduction needed 🔁,
+                      type: bug 🐛
+                  # Never touch these
+                  exempt-issue-labels: |
+                      type: feature ✨,
+                      type: chore 🧤,
+                      type: security 🔐,
+                      👋 contributions welcome,
+                      🚀 good first task,
+                      @vendure/admin-ui,
+                      @vendure/admin-ui-plugin,
+                      @vendure/asset-server-plugin,
+                      @vendure/cli,
+                      @vendure/core,
+                      @vendure/create,
+                      @vendure/dashboard,
+                      @vendure/elasticsearch-plugin,
+                      @vendure/email-plugin,
+                      @vendure/job-queue-plugin,
+                      @vendure/payments-plugin,
+                      @vendure/testing,
+                      @vendure/ui-devkit,
+                      P1: urgent,
+                      P2: important,
+                      P3: minor,
+                      P4: low
+                  stale-issue-label: stale
+                  close-issue-message: >
+                      Closed automatically because there has been no activity for {{days-before-close}}
+                      days after a reminder.  Please comment with new information and
+                      remove the label to re-open.
+                  operations-per-run: 1000

+ 467 - 0
docs/docs/guides/developer-guide/custom-strategies-in-plugins/index.mdx

@@ -0,0 +1,467 @@
+---
+title: 'Custom Strategies in Plugins'
+---
+
+When building Vendure plugins, you often need to provide extensible, pluggable implementations for specific features. The **strategy pattern** is the perfect tool for this, allowing plugin users to customize behavior by providing their own implementations.
+
+This guide shows you how to implement custom strategies in your plugins, following Vendure's established patterns and best practices.
+
+## Overview
+
+A strategy in Vendure is a way to provide a pluggable implementation of a particular feature. Custom strategies in plugins allow users to:
+
+- Override default behavior with their own implementations
+- Inject dependencies and services through the `init()` lifecycle method
+- Clean up resources using the `destroy()` lifecycle method
+- Configure the strategy through the plugin's init options
+
+## Creating a Strategy Interface
+
+First, define the interface that your strategy must implement. All strategy interfaces should extend `InjectableStrategy` to support dependency injection and lifecycle methods.
+
+```ts title="src/strategies/my-custom-strategy.ts"
+import { InjectableStrategy, RequestContext } from '@vendure/core';
+
+export interface MyCustomStrategy extends InjectableStrategy {
+    /**
+     * Process some data and return a result
+     */
+    processData(ctx: RequestContext, data: any): Promise<string>;
+
+    /**
+     * Validate the input data
+     */
+    validateInput(data: any): boolean;
+}
+```
+
+## Implementing a Default Strategy
+
+Create a default implementation that users can extend or replace:
+
+```ts title="src/strategies/default-my-custom-strategy.ts"
+import { Injector, RequestContext, Logger } from '@vendure/core';
+import { MyCustomStrategy } from './my-custom-strategy';
+import { SomeOtherService } from '../services/some-other.service';
+import { loggerCtx } from '../constants';
+
+export class DefaultMyCustomStrategy implements MyCustomStrategy {
+    private someOtherService: SomeOtherService;
+
+    async init(injector: Injector): Promise<void> {
+        // Inject dependencies during the init phase
+        this.someOtherService = injector.get(SomeOtherService);
+
+        // Perform any setup logic
+        Logger.info('DefaultMyCustomStrategy initialized', loggerCtx);
+    }
+
+    async destroy(): Promise<void> {
+        // Clean up resources if needed
+        Logger.info('DefaultMyCustomStrategy destroyed', loggerCtx);
+    }
+
+    async processData(ctx: RequestContext, data: any): Promise<string> {
+        // Validate input first
+        if (!this.validateInput(data)) {
+            throw new Error('Invalid input data');
+        }
+
+        // Use injected service to process data
+        const result = await this.someOtherService.doSomething(ctx, data);
+        // ... do something with the result
+        return result;
+    }
+
+    validateInput(data: any): boolean {
+        return data != null && typeof data === 'object';
+    }
+}
+```
+
+## Adding Strategy to Plugin Options
+
+Define your plugin's initialization options to include the strategy:
+
+```ts title="src/types.ts"
+import { MyCustomStrategy } from './strategies/my-custom-strategy';
+
+export interface MyPluginInitOptions {
+    /**
+     * Custom strategy for processing data
+     * @default DefaultMyCustomStrategy
+     */
+    processingStrategy?: MyCustomStrategy;
+
+    /**
+     * Other plugin options
+     */
+    someOtherOption?: string;
+}
+```
+
+## Configuring the Plugin
+
+In your plugin definition, provide the default strategy and allow users to override it:
+
+```ts title="src/my-plugin.ts"
+import { PluginCommonModule, VendurePlugin, Injector } from '@vendure/core';
+import { OnApplicationBootstrap, OnApplicationShutdown } from '@nestjs/common';
+import { ModuleRef } from '@nestjs/core';
+
+import { MY_PLUGIN_OPTIONS } from './constants';
+import { MyPluginInitOptions } from './types';
+import { DefaultMyCustomStrategy } from './strategies/default-my-custom-strategy';
+import { MyPluginService } from './services/my-plugin.service';
+import { SomeOtherService } from './services/some-other.service';
+
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    providers: [
+        MyPluginService,
+        SomeOtherService,
+        {
+            provide: MY_PLUGIN_OPTIONS,
+            useFactory: () => MyPlugin.options,
+        },
+    ],
+    configuration: config => {
+        // You can also configure core Vendure strategies here if needed
+        return config;
+    },
+    compatibility: '^3.0.0',
+})
+export class MyPlugin implements OnApplicationBootstrap, OnApplicationShutdown {
+    static options: MyPluginInitOptions;
+
+    constructor(private moduleRef: ModuleRef) {}
+
+    static init(options: MyPluginInitOptions) {
+        this.options = {
+            // Provide default strategy if none specified
+            processingStrategy: new DefaultMyCustomStrategy(),
+            ...options,
+        };
+        return MyPlugin;
+    }
+
+    async onApplicationBootstrap() {
+        await this.initStrategy();
+    }
+
+    async onApplicationShutdown() {
+        await this.destroyStrategy();
+    }
+
+    private async initStrategy() {
+        const strategy = MyPlugin.options.processingStrategy;
+        if (strategy && typeof strategy.init === 'function') {
+            const injector = new Injector(this.moduleRef);
+            await strategy.init(injector);
+        }
+    }
+
+    private async destroyStrategy() {
+        const strategy = MyPlugin.options.processingStrategy;
+        if (strategy && typeof strategy.destroy === 'function') {
+            await strategy.destroy();
+        }
+    }
+}
+```
+
+## Using the Strategy in Services
+
+Access the strategy through dependency injection in your services:
+
+```ts title="src/services/my-plugin.service.ts"
+import { Injectable, Inject } from '@nestjs/common';
+import { RequestContext } from '@vendure/core';
+
+import { MY_PLUGIN_OPTIONS } from '../constants';
+import { MyPluginInitOptions } from '../types';
+
+@Injectable()
+export class MyPluginService {
+    constructor(@Inject(MY_PLUGIN_OPTIONS) private options: MyPluginInitOptions) {}
+
+    async processUserData(ctx: RequestContext, userData: any): Promise<string> {
+        // Delegate to the configured strategy
+        return this.options.processingStrategy.processData(ctx, userData);
+    }
+
+    validateUserInput(data: any): boolean {
+        return this.options.processingStrategy.validateInput(data);
+    }
+}
+```
+
+## User Implementation Example
+
+Plugin users can now provide their own strategy implementations:
+
+```ts title="src/my-custom-implementation.ts"
+import { Injector, RequestContext, Logger } from '@vendure/core';
+import { MyCustomStrategy } from '@my-org/my-plugin';
+import { ExternalApiService } from './external-api.service';
+import { loggerCtx } from '../constants';
+
+export class CustomProcessingStrategy implements MyCustomStrategy {
+    private externalApi: ExternalApiService;
+
+    async init(injector: Injector): Promise<void> {
+        this.externalApi = injector.get(ExternalApiService);
+
+        // Initialize external API connection
+        await this.externalApi.connect();
+        Logger.info('Custom processing strategy initialized', loggerCtx);
+    }
+
+    async destroy(): Promise<void> {
+        // Clean up external connections
+        if (this.externalApi) {
+            await this.externalApi.disconnect();
+        }
+        Logger.info('Custom processing strategy destroyed', loggerCtx);
+    }
+
+    async processData(ctx: RequestContext, data: any): Promise<string> {
+        if (!this.validateInput(data)) {
+            throw new Error('Invalid data format');
+        }
+
+        // Use external API for processing
+        const result = await this.externalApi.processData(data);
+        return `Processed: ${result}`;
+    }
+
+    validateInput(data: any): boolean {
+        // Custom validation logic
+        return data && data.type === 'custom' && data.value;
+    }
+}
+```
+
+## Plugin Configuration by Users
+
+Users configure the plugin with their custom strategy:
+
+```ts title="vendure-config.ts"
+import { VendureConfig } from '@vendure/core';
+import { MyPlugin } from '@my-org/my-plugin';
+import { CustomProcessingStrategy } from './my-custom-implementation';
+
+export const config: VendureConfig = {
+    plugins: [
+        MyPlugin.init({
+            processingStrategy: new CustomProcessingStrategy(),
+            someOtherOption: 'custom-value',
+        }),
+    ],
+    // ... other config
+};
+```
+
+## Strategy with Options
+
+You can also create strategies that accept configuration options:
+
+```ts title="src/strategies/configurable-strategy.ts"
+import { Injector, RequestContext } from '@vendure/core';
+import { MyCustomStrategy } from './my-custom-strategy';
+
+export interface ConfigurableStrategyOptions {
+    timeout: number;
+    retries: number;
+    apiKey: string;
+}
+
+export class ConfigurableStrategy implements MyCustomStrategy {
+    constructor(private options: ConfigurableStrategyOptions) {}
+
+    async init(injector: Injector): Promise<void> {
+        // Use options during initialization
+        console.log(`Strategy configured with timeout: ${this.options.timeout}ms`);
+    }
+
+    async destroy(): Promise<void> {
+        // Cleanup logic
+    }
+
+    async processData(ctx: RequestContext, data: any): Promise<string> {
+        // Use configuration options
+        const timeout = this.options.timeout;
+        const retries = this.options.retries;
+
+        // Implementation using these options...
+        return 'processed with options';
+    }
+
+    validateInput(data: any): boolean {
+        return true;
+    }
+}
+```
+
+Usage:
+
+```ts title="vendure-config.ts"
+import { ConfigurableStrategy } from './strategies/configurable-strategy';
+
+// In plugin configuration
+MyPlugin.init({
+    processingStrategy: new ConfigurableStrategy({
+        timeout: 5000,
+        retries: 3,
+        apiKey: process.env.API_KEY,
+    }),
+});
+```
+
+## Multiple Strategies in One Plugin
+
+For complex plugins, you might need multiple strategies:
+
+```ts title="src/types.ts"
+export interface ComplexPluginOptions {
+    dataProcessingStrategy?: DataProcessingStrategy;
+    validationStrategy?: ValidationStrategy;
+    cacheStrategy?: CacheStrategy;
+}
+```
+
+```ts title="src/complex-plugin.ts"
+@VendurePlugin({
+    // ... plugin config
+})
+export class ComplexPlugin implements OnApplicationBootstrap, OnApplicationShutdown {
+    static options: ComplexPluginOptions;
+
+    static init(options: ComplexPluginOptions) {
+        this.options = {
+            dataProcessingStrategy: new DefaultDataProcessingStrategy(),
+            validationStrategy: new DefaultValidationStrategy(),
+            cacheStrategy: new DefaultCacheStrategy(),
+            ...options,
+        };
+        return ComplexPlugin;
+    }
+
+    async onApplicationBootstrap() {
+        await this.initAllStrategies();
+    }
+
+    async onApplicationShutdown() {
+        await this.destroyAllStrategies();
+    }
+
+    private async initAllStrategies() {
+        const injector = new Injector(this.moduleRef);
+        const strategies = [
+            ComplexPlugin.options.dataProcessingStrategy,
+            ComplexPlugin.options.validationStrategy,
+            ComplexPlugin.options.cacheStrategy,
+        ];
+
+        for (const strategy of strategies) {
+            if (strategy && typeof strategy.init === 'function') {
+                await strategy.init(injector);
+            }
+        }
+    }
+
+    private async destroyAllStrategies() {
+        const strategies = [
+            ComplexPlugin.options.dataProcessingStrategy,
+            ComplexPlugin.options.validationStrategy,
+            ComplexPlugin.options.cacheStrategy,
+        ];
+
+        for (const strategy of strategies) {
+            if (strategy && typeof strategy.destroy === 'function') {
+                await strategy.destroy();
+            }
+        }
+    }
+}
+```
+
+## Best Practices
+
+### 1. Always Extend InjectableStrategy
+
+```ts
+export interface MyStrategy extends InjectableStrategy {
+    // ... strategy methods
+}
+```
+
+### 2. Provide Sensible Defaults
+
+Always provide a default implementation so users can use your plugin out-of-the-box:
+
+```ts
+static init(options: MyPluginOptions) {
+    this.options = {
+        myStrategy: new DefaultMyStrategy(),
+        ...options,
+    };
+    return MyPlugin;
+}
+```
+
+### 3. Handle Lifecycle Properly
+
+Always implement proper init/destroy handling in your plugin:
+
+```ts
+async onApplicationBootstrap() {
+    await this.initStrategies();
+}
+
+async onApplicationShutdown() {
+    await this.destroyStrategies();
+}
+```
+
+### 4. Use TypeScript for Better DX
+
+Provide strong typing for better developer experience:
+
+```ts
+export interface MyStrategy extends InjectableStrategy {
+    processData<T>(ctx: RequestContext, data: T): Promise<ProcessedResult<T>>;
+}
+```
+
+### 5. Document Your Strategy Interface
+
+Provide comprehensive JSDoc comments:
+
+```ts
+export interface MyStrategy extends InjectableStrategy {
+    /**
+     * @description
+     * Processes the input data and returns a transformed result.
+     * This method is called for each data processing request.
+     *
+     * @param ctx - The current request context
+     * @param data - The input data to process
+     * @returns Promise resolving to the processed result
+     */
+    processData(ctx: RequestContext, data: any): Promise<string>;
+}
+```
+
+## Summary
+
+Custom strategies in plugins provide a powerful way to make your plugins extensible and configurable. By following the patterns outlined in this guide, you can:
+
+- Define clear strategy interfaces that extend `InjectableStrategy`
+- Provide default implementations that work out-of-the-box
+- Allow users to inject dependencies through the `init()` method
+- Properly manage strategy lifecycle with `init()` and `destroy()` methods
+- Enable users to provide their own implementations
+- Support configuration options for strategies
+
+This approach ensures your plugins are flexible, maintainable, and follow Vendure's established conventions.

+ 37 - 30
docs/docs/guides/getting-started/installation/index.md

@@ -7,13 +7,13 @@ import Tabs from '@theme/Tabs';
 import TabItem from '@theme/TabItem';
 
 ## Requirements
- 
+
 * [Node.js](https://nodejs.org/en/) **v20** or above, with support for **even-numbered Node.js versions**. (Odd-numbered versions should still work but are not officially supported.)
 
 ### Optional
-* [Docker Desktop](https://www.docker.com/products/docker-desktop/): If you want to use the quick start with Postgres, you must have Docker Desktop installed. If you do not have Docker Desktop installed 
-  then SQLite will be used for your database.
-* If you want to use an existing MySQL, MariaDB, or Postgres server as your data store, then you'll need an instance available locally. However, **if you are just testing out Vendure, we recommend the quick start flow, which handles the database for you**.
+
+* [Docker Desktop](https://www.docker.com/products/docker-desktop/): If you want to use the quick start with Postgres, you must have Docker Desktop installed. If you do not have Docker Desktop installed, then SQLite will be used for your database.
+* If you want to use an existing MySQL, MariaDB, or Postgres server as your data store, then you'll need an instance available locally. However, **if you are just testing out Vendure, we recommend the quick start option, which handles the database for you**.
 
 ## @vendure/create
 
@@ -21,56 +21,64 @@ The recommended way to get started with Vendure is by using the [@vendure/create
 
 ### Quick Start
 
-First run the following command in your terminal, replacing `my-shop` with the name of your project:
+First, run the following command in your terminal, replacing `my-shop` with the name of your project:
 
 ```bash
 npx @vendure/create my-shop
 ```
 
-Next choose the "Quick Start" option. This is the fastest way to get a Vendure server up and running, and will handle
-all the configuration for you. If you have Docker Desktop installed, it will create and configure a Postgres database for you. If not, it will use SQLite.
+Next, choose the "Quick Start" option. This is the fastest way to get a Vendure server up and running and will handle all the configuration for you. If you have Docker Desktop installed, it will create and configure a Postgres database for you. If not, it will use SQLite.
 
 ```text
 ┌  Let's create a Vendure App ✨
 ◆  How should we proceed?
 // highlight-next-line
-│  ● Quick Start (Get up an running in a single step)
+│  ● Quick Start (Get up and running in a single step)
 │  ○ Manual Configuration
 ```
 
-And that's it! After a minute or two you'll have a fully-functional Vendure server running locally.
+And that's it! After a minute or two, you'll have a **fully-functional Vendure server** installed locally.
+
+Once the installation is done, your terminal will output a message indicating a successful installation with:
+
+* The URL to access the **Admin UI**
+* Your admin log-in credentials
+* The project file path
+
+Proceed to the [Start the server](#start-the-server) section below to run your Vendure server.
 
 ### Manual Configuration
 
-If you'd rather have more control over the configuration, you can choose the "Manual Configuration" option. 
-This will prompt you to select a database, and whether to populate the database with sample data.
+If you'd rather have more control over the configuration, you can choose the "Manual Configuration" option. This will prompt you to select a database and whether to populate the database with sample data.
 
 #### 1. Select a database
 
-Vendure supports a number of different databases. The `@vendure/create` tool will prompt you to select one. 
-
-**To quickly test out Vendure, we recommend using SQLite**, which requires no external dependencies. You can always switch to a different database later by changing your configuration file.
+Vendure supports a number of different databases. The `@vendure/create` tool will prompt you to select one.
 
+**To quickly test out Vendure, we recommend using SQLite**, which requires no external dependencies. You can always switch to a different database later [by changing your configuration file](/guides/developer-guide/configuration/#connecting-to-the-database).
 
 ![Vendure Create step 1](./create-1.webp)
 
 :::tip
-If you select MySQL, MariaDB or Postgres, you need to make sure you:
+If you select MySQL, MariaDB, or Postgres, you need to make sure you:
+
+1. **Have the database server running**: You can either install the database locally on your machine, use a cloud provider, or run it via Docker. For local development with Docker, you can use the provided `docker-compose.yml` file in your project.
+
+2. **Have created a database**: Use your database client to create an empty database (e.g., `CREATE DATABASE vendure;` in most SQL databases).
+
+3. **Have database credentials**: You need the username and password for a database user that has full permissions (CREATE, ALTER, DROP, INSERT, UPDATE, DELETE, SELECT) on the database you created.
+
+For detailed database configuration examples, see the [Configuration guide](/guides/developer-guide/configuration/#connecting-to-the-database).
 
-1. have the database server running and accessible
-2. have created a database for Vendure to use
-3. know the username and password for a user with access to that database
 :::
 
 #### 2. Populate with data
 
 The final prompt will ask whether to populate your new Vendure server with some sample product data.
 
-**We recommend you do so**, as it will give you a good starting point for exploring the APIs which we will cover 
-in the [Try the API section](/guides/getting-started/try-the-api/), as well as providing some data to use when
-building your own storefront.
+**We recommend you do so**, as it will give you a good starting point for exploring the APIs, which we will cover in the [Try the API section](/guides/getting-started/try-the-api/), as well as providing some data to use when building your own storefront.
 
 ![Vendure Create step 2](./create-2.webp)
 
@@ -82,7 +90,6 @@ Once complete, you'll see a message like this:
 
 ![Vendure Create step 3](./create-3.webp)
 
-
 ### Start the server
 
 Follow the instructions to move into the new directory created for your project, and start the server:
@@ -110,8 +117,8 @@ Open the Admin UI at [http://localhost:3000/admin](http://localhost:3000/admin)
 
 * **username**: superadmin
 * **password**: superadmin
-:::
 
+:::
 
 :::cli
 Use `npx vendure add` to start adding plugins & custom functionality to your Vendure server.
@@ -119,19 +126,19 @@ Use `npx vendure add` to start adding plugins & custom functionality to your Ven
 
 ### Troubleshooting
 
-- If you encounter any issues during installation, you can get a more detailed output by setting the log level to `verbose`:
+* If you encounter any issues during installation, you can get a more detailed output by setting the log level to `verbose`:
+
    ```sh
    npx @vendure/create my-shop --log-level verbose
    ```
-- The [supported TypeScript version](https://github.com/vendure-ecommerce/vendure/blob/master/packages/create/src/constants.ts#L7) is set upon installation. Upgrading to a newer version of TypeScript might result in compilation errors because
-  TypeScript sometimes introduces stricter checks in newer versions. 
-- If you want to use **Yarn**, from Vendure v2.2.0+, you'll need to use **Yarn 2** (Berry) or above.
+
+* The [supported TypeScript version](https://github.com/vendure-ecommerce/vendure/blob/master/packages/create/src/constants.ts#L7) is set upon installation. Upgrading to a newer version of TypeScript might result in compilation errors because TypeScript sometimes introduces stricter checks in newer versions.
+* If you want to use **Yarn**, from Vendure v2.2.0+, you'll need to use **Yarn 2** (Berry) or above.
 
 ## Set up a storefront
 
-Once you have a Vendure server running, you can set up a storefront to interact with it! 
+Once you have a Vendure server running, you can set up a storefront to interact with it!
 
-We have a number of storefront starter kits available for you to use - head over to the [Storefront Starters](/guides/storefront/storefront-starters/)
-page to learn more.
+We have a number of storefront starter kits available for you to use—head over to the [Storefront Starters](/guides/storefront/storefront-starters/) page to learn more.
 
 ![Remix storefront](../../storefront/storefront-starters/remix-storefront.webp)

+ 3 - 4
docs/sidebars.js

@@ -30,7 +30,7 @@ const icon = {
     <ellipse rx="11" ry="4.2" transform="rotate(60)"/>
     <ellipse rx="11" ry="4.2" transform="rotate(120)"/>
   </g>
-</svg>`
+</svg>`,
 };
 
 /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
@@ -100,6 +100,7 @@ const sidebars = {
                     value: 'Advanced Topics',
                     className: 'sidebar-section-header',
                 },
+                'guides/developer-guide/custom-strategies-in-plugins/index',
                 'guides/developer-guide/channel-aware/index',
                 'guides/developer-guide/translateable/index',
                 'guides/developer-guide/cache/index',
@@ -173,9 +174,7 @@ const sidebars = {
             customProps: {
                 icon: icon.reactLogo,
             },
-            items: [
-                'guides/extending-the-dashboard/getting-started/index',
-            ]
+            items: ['guides/extending-the-dashboard/getting-started/index'],
         },
         {
             type: 'category',

+ 29 - 2
packages/cli/src/commands/migrate/load-vendure-config-file.ts

@@ -1,4 +1,5 @@
 import { VendureConfig } from '@vendure/core';
+import { readFileSync } from 'node:fs';
 import path from 'node:path';
 import { register } from 'ts-node';
 
@@ -6,6 +7,13 @@ import { VendureConfigRef } from '../../shared/vendure-config-ref';
 import { selectTsConfigFile } from '../../utilities/ast-utils';
 import { isRunningInTsNode } from '../../utilities/utils';
 
+function stripJsonComments(jsonString: string): string {
+    return jsonString
+        .replace(/\/\*[\s\S]+?\*\//g, '')
+        .replace(/\/\/.*$/gm, '')
+        .replace(/^\s*$[\r\n]/gm, '');
+}
+
 export async function loadVendureConfigFile(
     vendureConfig: VendureConfigRef,
     providedTsConfigPath?: string,
@@ -19,12 +27,31 @@ export async function loadVendureConfigFile(
             const tsConfigFile = await selectTsConfigFile();
             tsConfigPath = path.join(process.cwd(), tsConfigFile);
         }
-        // eslint-disable-next-line @typescript-eslint/no-var-requires
-        const compilerOptions = require(tsConfigPath).compilerOptions;
+
+        let tsConfigFileContent: string;
+        let tsConfigJson: any;
+
+        try {
+            tsConfigFileContent = readFileSync(tsConfigPath, 'utf-8');
+        } catch (error: unknown) {
+            const errorMessage = error instanceof Error ? error.message : String(error);
+            throw new Error(`Failed to read TypeScript config file at ${tsConfigPath}: ${errorMessage}`);
+        }
+
+        try {
+            tsConfigJson = JSON.parse(stripJsonComments(tsConfigFileContent));
+        } catch (error: unknown) {
+            const errorMessage = error instanceof Error ? error.message : String(error);
+            throw new Error(`Failed to parse TypeScript config file at ${tsConfigPath}: ${errorMessage}`);
+        }
+
+        const compilerOptions = tsConfigJson.compilerOptions;
+
         register({
             compilerOptions: { ...compilerOptions, moduleResolution: 'NodeNext', module: 'NodeNext' },
             transpileOnly: true,
         });
+
         if (compilerOptions.paths) {
             // eslint-disable-next-line @typescript-eslint/no-var-requires
             const tsConfigPaths = require('tsconfig-paths');

+ 1 - 1
packages/core/e2e/shipping-method.e2e-spec.ts

@@ -136,7 +136,7 @@ describe('ShippingMethod resolver', () => {
                         description: null,
                         label: 'Tax rate',
                         name: 'taxRate',
-                        type: 'int',
+                        type: 'float',
                     },
                 ],
                 code: 'default-shipping-calculator',

+ 1 - 1
packages/core/src/config/shipping-method/default-shipping-calculator.ts

@@ -43,7 +43,7 @@ export const defaultShippingCalculator = new ShippingCalculator({
             label: [{ languageCode: LanguageCode.en, value: 'Price includes tax' }],
         },
         taxRate: {
-            type: 'int',
+            type: 'float',
             defaultValue: 0,
             ui: { component: 'number-form-input', suffix: '%' },
             label: [{ languageCode: LanguageCode.en, value: 'Tax rate' }],

+ 2 - 9
packages/core/src/config/tax/address-based-tax-zone-strategy.spec.ts

@@ -1,7 +1,6 @@
-import { RequestContext, Zone, Channel, Order } from '@vendure/core';
 import { describe, it, expect } from 'vitest';
 
-import { Logger } from '../logger/vendure-logger';
+import { RequestContext, Zone, Channel, Order } from '../../';
 
 import { AddressBasedTaxZoneStrategy } from './address-based-tax-zone-strategy';
 
@@ -18,18 +17,12 @@ describe('AddressBasedTaxZoneStrategy', () => {
         { name: 'DE Zone', members: [{ code: 'DE' }] } as Zone,
     ];
 
-    it('Determines zone based on billing address country', () => {
+    it('Determines zone based on shipping address country', () => {
         const order = {
             billingAddress: { countryCode: 'US' },
             shippingAddress: { countryCode: 'DE' },
         } as Order;
         const result = strategy.determineTaxZone(ctx, zones, channel, order);
-        expect(result.name).toBe('US Zone');
-    });
-
-    it('Determines zone based on shipping address if no billing address set', () => {
-        const order = { shippingAddress: { countryCode: 'DE' } } as Order;
-        const result = strategy.determineTaxZone(ctx, zones, channel, order);
         expect(result.name).toBe('DE Zone');
     });
 

+ 3 - 2
packages/core/src/config/tax/address-based-tax-zone-strategy.ts

@@ -9,7 +9,8 @@ const loggerCtx = 'AddressBasedTaxZoneStrategy';
 /**
  * @description
  * Address based {@link TaxZoneStrategy} which tries to find the applicable {@link Zone} based on the
- * country of the billing address, or else the country of the shipping address of the Order.
+ * country of the shipping address of the Order.
+ * This is useful for shops that do cross-border B2C orders and use the One-Stop-Shop (OSS) VAT scheme.
  *
  * Returns the default {@link Channel}'s default tax zone if no applicable zone is found.
  *
@@ -38,7 +39,7 @@ const loggerCtx = 'AddressBasedTaxZoneStrategy';
  */
 export class AddressBasedTaxZoneStrategy implements TaxZoneStrategy {
     determineTaxZone(ctx: RequestContext, zones: Zone[], channel: Channel, order?: Order): Zone {
-        const countryCode = order?.billingAddress?.countryCode ?? order?.shippingAddress?.countryCode;
+        const countryCode = order?.shippingAddress?.countryCode;
         if (order && countryCode) {
             const zone = zones.find(z => z.members?.find(member => member.code === countryCode));
             if (zone) {

+ 4 - 2
packages/dashboard/src/app/main.tsx

@@ -1,5 +1,7 @@
 import { Toaster } from '@/components/ui/sonner.js';
+import { registerDefaults } from '@/framework/defaults.js';
 import { setCustomFieldsMap } from '@/framework/document-introspection/add-custom-fields.js';
+import { executeDashboardExtensionCallbacks } from '@/framework/extension-api/define-dashboard-extension.js';
 import { useDashboardExtensions } from '@/framework/extension-api/use-dashboard-extensions.js';
 import { useExtendedRouter } from '@/framework/page/use-extended-router.js';
 import { useAuth } from '@/hooks/use-auth.js';
@@ -8,8 +10,6 @@ import { defaultLocale, dynamicActivate } from '@/providers/i18n-provider.js';
 import { createRouter, RouterProvider } from '@tanstack/react-router';
 import React, { useEffect } from 'react';
 import ReactDOM from 'react-dom/client';
-import { registerDefaults } from '@/framework/defaults.js';
-import { executeDashboardExtensionCallbacks } from '@/framework/extension-api/define-dashboard-extension.js';
 
 import { AppProviders, queryClient } from './app-providers.js';
 import { routeTree } from './routeTree.gen.js';
@@ -26,6 +26,8 @@ export const router = createRouter({
     routeTree,
     defaultPreload: 'intent',
     scrollRestoration: true,
+    // In case the dashboard gets served from a subpath, we need to set the basepath based on the environment variable
+    ...(import.meta.env.BASE_URL ? { basepath: import.meta.env.BASE_URL } : {}),
     context: {
         /* eslint-disable @typescript-eslint/no-non-null-assertion */
         auth: undefined!, // This will be set after we wrap the app in an AuthProvider

+ 6 - 35
packages/dashboard/src/app/routes/_authenticated/_product-variants/product-variants.tsx

@@ -1,6 +1,6 @@
 import { Money } from '@/components/data-display/money.js';
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
-import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
+import { StockLevelLabel } from '@/components/shared/stock-level-label.js';
 import { ListPage } from '@/framework/page/list-page.js';
 import { useLocalFormat } from '@/hooks/use-local-format.js';
 import { Trans } from '@/lib/trans.js';
@@ -23,48 +23,19 @@ function ProductListPage() {
             customizeColumns={{
                 name: {
                     header: 'Product Name',
-                    cell: ({ row }) => <DetailPageButton id={row.original.id} label={row.original.name} />,
+                    cell: ({ row: { original } }) => <DetailPageButton id={original.id} label={original.name} />,
                 },
                 currencyCode: {
-                    cell: ({ cell, row }) => {
-                        const value = cell.getValue();
-                        return formatCurrencyName(value as string, 'full');
-                    },
+                    cell: ({ row: { original } }) => formatCurrencyName(original.currencyCode, 'full'),
                 },
                 price: {
-                    cell: ({ cell, row }) => {
-                        const value = cell.getValue();
-                        const currencyCode = row.original.currencyCode;
-                        if (typeof value === 'number') {
-                            return <Money value={value} currency={currencyCode} />;
-                        }
-                        return value;
-                    },
+                    cell: ({ row: { original } }) => <Money value={original.price} currency={original.currencyCode} />,
                 },
                 priceWithTax: {
-                    cell: ({ cell, row }) => {
-                        const value = cell.getValue();
-                        const currencyCode = row.original.currencyCode;
-                        if (typeof value === 'number') {
-                            return <Money value={value} currency={currencyCode} />;
-                        }
-                        return value;
-                    },
+                    cell: ({ row: { original } }) => <Money value={original.priceWithTax} currency={original.currencyCode} />,
                 },
                 stockLevels: {
-                    cell: ({ cell, row }) => {
-                        const value = cell.getValue();
-                        if (Array.isArray(value)) {
-                            const totalOnHand = value.reduce((acc, curr) => acc + curr.stockOnHand, 0);
-                            const totalAllocated = value.reduce((acc, curr) => acc + curr.stockAllocated, 0);
-                            return (
-                                <span>
-                                    {totalOnHand} / {totalAllocated}
-                                </span>
-                            );
-                        }
-                        return value;
-                    },
+                    cell: ({ row: { original } }) => <StockLevelLabel stockLevels={original.stockLevels} />,
                 },
             }}
             onSearchTermChange={searchTerm => {

+ 13 - 36
packages/dashboard/src/app/routes/_authenticated/_products/components/product-variants-table.tsx

@@ -1,11 +1,11 @@
-import { PaginatedListDataTable, PaginatedListRefresherRegisterFn } from "@/components/shared/paginated-list-data-table.js";
-import { productVariantListDocument } from "../products.graphql.js";
-import { useState } from "react";
-import { ColumnFiltersState, SortingState } from "@tanstack/react-table";
 import { Money } from "@/components/data-display/money.js";
+import { PaginatedListDataTable, PaginatedListRefresherRegisterFn } from "@/components/shared/paginated-list-data-table.js";
+import { StockLevelLabel } from "@/components/shared/stock-level-label.js";
 import { useLocalFormat } from "@/hooks/use-local-format.js";
-import { Link } from "@tanstack/react-router";
-import { Button } from "@/components/ui/button.js";
+import { DetailPageButton } from "@/index.js";
+import { ColumnFiltersState, SortingState } from "@tanstack/react-table";
+import { useState } from "react";
+import { productVariantListDocument } from "../products.graphql.js";
 
 interface ProductVariantsTableProps {
     productId: string;
@@ -33,42 +33,19 @@ export function ProductVariantsTable({ productId, registerRefresher }: ProductVa
         customizeColumns={{
             name: {
                 header: 'Variant name',
-                cell: ({ row }) => {
-                    const variant = row.original as any;
-                    return (
-                        <Button asChild variant="ghost">
-                            <Link to={`../../product-variants/${variant.id}`}>{variant.name} </Link>
-                        </Button>
-                    );
-                },
+                cell: ({ row: { original } }) => <DetailPageButton href={`../../product-variants/${original.id}`} label={original.name} />,
             },
             currencyCode: {
-                cell: ({ cell, row }) => {
-                    const value = cell.getValue();
-                    return formatCurrencyName(value as string, 'full');
-                },
+                cell: ({ row: { original } }) => formatCurrencyName(original.currencyCode, 'full'),
             },
             price: {
-                cell: ({ cell, row }) => {
-                    const variant = row.original as any;
-                    const value = cell.getValue();
-                    const currencyCode = variant.currencyCode;
-                    if (typeof value === 'number') {
-                        return <Money value={value} currency={currencyCode} />;
-                    }
-                    return value;
-                },
+                cell: ({ row: { original } }) => <Money value={original.price} currency={original.currencyCode} />,
             },
             priceWithTax: {
-                cell: ({ cell, row }) => {
-                    const variant = row.original as any;
-                    const value = cell.getValue();
-                    const currencyCode = variant.currencyCode;
-                    if (typeof value === 'number') {
-                        return <Money value={value} currency={currencyCode} />;
-                    }
-                    return value;
-                },
+                cell: ({ row: { original } }) => <Money value={original.priceWithTax} currency={original.currencyCode} />,
+            },
+            stockLevels: {
+                cell: ({ row: { original } }) => <StockLevelLabel stockLevels={original.stockLevels} />,
             },
         }}
         page={page}

+ 5 - 0
packages/dashboard/src/app/routes/_authenticated/_products/products.graphql.ts

@@ -71,7 +71,12 @@ export const productVariantListDocument = graphql(`
                 currencyCode
                 price
                 priceWithTax
+                stockLevels {
+                    stockOnHand
+                    stockAllocated
+                }
             }
+            totalItems
         }
     }
 `);

+ 7 - 2
packages/dashboard/src/lib/components/shared/detail-page-button.tsx

@@ -4,16 +4,21 @@ import { Button } from '../ui/button.js';
 
 export function DetailPageButton({
     id,
+    href,
     label,
     disabled,
 }: {
-    id: string;
     label: string | React.ReactNode;
+    id?: string;
+    href?: string;
     disabled?: boolean;
 }) {
+    if (!id && !href) {
+        return <span>{label}</span>;
+    }
     return (
         <Button asChild variant="ghost" disabled={disabled}>
-            <Link to={`./${id}`}>
+            <Link to={href ?? `./${id}`}>
                 {label}
                 {!disabled && <ChevronRight className="h-3 w-3 text-muted-foreground" />}
             </Link>

+ 13 - 5
packages/dashboard/src/lib/components/shared/paginated-list-data-table.tsx

@@ -114,6 +114,14 @@ export type FacetedFilterConfig<T extends TypedDocumentNode<any, any>> = {
     [Key in AllItemFieldKeys<T>]?: FacetedFilter;
 };
 
+export type ListQueryFields<T extends TypedDocumentNode<any, any>> = {
+    [Key in keyof ResultOf<T>]: ResultOf<T>[Key] extends { items: infer U }
+        ? U extends any[]
+            ? U[number]
+            : never
+        : never;
+}[keyof ResultOf<T>];
+
 export type ListQueryShape =
     | {
           [key: string]: {
@@ -185,7 +193,7 @@ export type PaginatedListRefresherRegisterFn = (refreshFn: () => void) => void;
 
 export interface PaginatedListDataTableProps<
     T extends TypedDocumentNode<U, V>,
-    U extends any,
+    U extends ListQueryShape,
     V extends ListQueryOptionsShape,
     AC extends AdditionalColumns<T>,
 > {
@@ -195,7 +203,7 @@ export interface PaginatedListDataTableProps<
     transformVariables?: (variables: V) => V;
     customizeColumns?: CustomizeColumnConfig<T>;
     additionalColumns?: AC;
-    defaultColumnOrder?: (AllItemFieldKeys<T> | AC[number]['id'])[];
+    defaultColumnOrder?: (keyof ListQueryFields<T> | keyof AC | CustomFieldKeysOfItem<ListQueryFields<T>>)[];
     defaultVisibility?: Partial<Record<AllItemFieldKeys<T>, boolean>>;
     onSearchTermChange?: (searchTerm: string) => NonNullable<V['options']>['filter'];
     page: number;
@@ -407,10 +415,10 @@ export function PaginatedListDataTable<
             // appear as the first columns in sequence, and leave the remainder in the
             // existing order
             const orderedColumns = finalColumns
-                .filter(column => column.id && defaultColumnOrder.includes(column.id))
-                .sort((a, b) => defaultColumnOrder.indexOf(a.id) - defaultColumnOrder.indexOf(b.id));
+                .filter(column => column.id && defaultColumnOrder.includes(column.id as any))
+                .sort((a, b) => defaultColumnOrder.indexOf(a.id as any) - defaultColumnOrder.indexOf(b.id as any));
             const remainingColumns = finalColumns.filter(
-                column => !column.id || !defaultColumnOrder.includes(column.id),
+                column => !column.id || !defaultColumnOrder.includes(column.id as any),
             );
             finalColumns = [...orderedColumns, ...remainingColumns];
         }

+ 24 - 0
packages/dashboard/src/lib/components/shared/stock-level-label.tsx

@@ -0,0 +1,24 @@
+import { useLingui } from '../../lib/trans.js';
+
+export type StockLevel = {
+    stockOnHand: number;
+    stockAllocated: number;
+};
+
+export function StockLevelLabel({ stockLevels }: { stockLevels: StockLevel[] }) {
+    const { i18n } = useLingui();
+    
+    if (!Array.isArray(stockLevels)) {
+        return null;
+    }
+    const totalOnHand = stockLevels.reduce((acc, curr) => acc + curr.stockOnHand, 0);
+    const totalAllocated = stockLevels.reduce((acc, curr) => acc + curr.stockAllocated, 0);
+    
+    return (
+        <span 
+            title={`${i18n.t('Stock on hand')}: ${totalOnHand}, ${i18n.t('Stock allocated')}: ${totalAllocated}`}
+        >
+            {totalOnHand} <span className="text-muted-foreground">/ {totalAllocated}</span>
+        </span>
+    );
+}

+ 24 - 0
packages/dashboard/src/lib/framework/defaults.ts

@@ -37,26 +37,31 @@ export function registerDefaults() {
                         id: 'products',
                         title: 'Products',
                         url: '/products',
+                        order: 100,
                     },
                     {
                         id: 'product-variants',
                         title: 'Product Variants',
                         url: '/product-variants',
+                        order: 200,
                     },
                     {
                         id: 'facets',
                         title: 'Facets',
                         url: '/facets',
+                        order: 300,
                     },
                     {
                         id: 'collections',
                         title: 'Collections',
                         url: '/collections',
+                        order: 400,
                     },
                     {
                         id: 'assets',
                         title: 'Assets',
                         url: '/assets',
+                        order: 500,
                     },
                 ],
             },
@@ -72,6 +77,7 @@ export function registerDefaults() {
                         id: 'orders',
                         title: 'Orders',
                         url: '/orders',
+                        order: 100,
                     },
                 ],
             },
@@ -87,11 +93,13 @@ export function registerDefaults() {
                         id: 'customers',
                         title: 'Customers',
                         url: '/customers',
+                        order: 100,
                     },
                     {
                         id: 'customer-groups',
                         title: 'Customer Groups',
                         url: '/customer-groups',
+                        order: 200,
                     },
                 ],
             },
@@ -107,6 +115,7 @@ export function registerDefaults() {
                         id: 'promotions',
                         title: 'Promotions',
                         url: '/promotions',
+                        order: 100,
                     },
                 ],
             },
@@ -122,16 +131,19 @@ export function registerDefaults() {
                         id: 'job-queue',
                         title: 'Job Queue',
                         url: '/job-queue',
+                        order: 100,
                     },
                     {
                         id: 'healthchecks',
                         title: 'Healthchecks',
                         url: '/healthchecks',
+                        order: 200,
                     },
                     {
                         id: 'scheduled-tasks',
                         title: 'Scheduled Tasks',
                         url: '/scheduled-tasks',
+                        order: 300,
                     },
                 ],
             },
@@ -147,61 +159,73 @@ export function registerDefaults() {
                         id: 'sellers',
                         title: 'Sellers',
                         url: '/sellers',
+                        order: 100,
                     },
                     {
                         id: 'channels',
                         title: 'Channels',
                         url: '/channels',
+                        order: 200,
                     },
                     {
                         id: 'stock-locations',
                         title: 'Stock Locations',
                         url: '/stock-locations',
+                        order: 300,
                     },
                     {
                         id: 'administrators',
                         title: 'Administrators',
                         url: '/administrators',
+                        order: 400,
                     },
                     {
                         id: 'roles',
                         title: 'Roles',
                         url: '/roles',
+                        order: 500,
                     },
                     {
                         id: 'shipping-methods',
                         title: 'Shipping Methods',
                         url: '/shipping-methods',
+                        order: 600,
                     },
                     {
                         id: 'payment-methods',
                         title: 'Payment Methods',
                         url: '/payment-methods',
+                        order: 700,
                     },
                     {
                         id: 'tax-categories',
                         title: 'Tax Categories',
                         url: '/tax-categories',
+                        order: 800,
                     },
                     {
                         id: 'tax-rates',
                         title: 'Tax Rates',
                         url: '/tax-rates',
+                        order: 900,
                     },
                     {
                         id: 'countries',
                         title: 'Countries',
                         url: '/countries',
+                        order: 1000,
                     },
                     {
                         id: 'zones',
                         title: 'Zones',
                         url: '/zones',
+                        order: 1100,
                     },
                     {
                         id: 'global-settings',
                         title: 'Global Settings',
                         url: '/global-settings',
+                        order: 1200,
                     },
                 ],
             },

+ 1 - 9
packages/dashboard/src/lib/framework/page/list-page.tsx

@@ -5,6 +5,7 @@ import {
     FacetedFilterConfig,
     ListQueryOptionsShape,
     ListQueryShape,
+    ListQueryFields,
     PaginatedListDataTable,
     RowAction,
 } from '@/components/shared/paginated-list-data-table.js';
@@ -13,7 +14,6 @@ import { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { AnyRoute, AnyRouter, useNavigate } from '@tanstack/react-router';
 import { ColumnFiltersState, SortingState, Table } from '@tanstack/react-table';
 import { TableOptions } from '@tanstack/table-core';
-import { ResultOf } from 'gql.tada';
 
 import { addCustomFields } from '../document-introspection/add-custom-fields.js';
 import {
@@ -24,14 +24,6 @@ import {
     PageTitle,
 } from '../layout-engine/page-layout.js';
 
-type ListQueryFields<T extends TypedDocumentNode<any, any>> = {
-    [Key in keyof ResultOf<T>]: ResultOf<T>[Key] extends { items: infer U }
-        ? U extends any[]
-            ? U[number]
-            : never
-        : never;
-}[keyof ResultOf<T>];
-
 /**
  * @description
  * **Status: Developer Preview**

+ 1 - 0
packages/dashboard/src/lib/index.ts

@@ -67,6 +67,7 @@ export * from './components/shared/rich-text-editor.js';
 export * from './components/shared/role-code-label.js';
 export * from './components/shared/role-selector.js';
 export * from './components/shared/seller-selector.js';
+export * from './components/shared/stock-level-label.js';
 export * from './components/shared/tax-category-selector.js';
 export * from './components/shared/translatable-form-field.js';
 export * from './components/shared/vendure-image.js';

+ 1 - 1
packages/dashboard/vite/vite-plugin-config.ts

@@ -26,7 +26,7 @@ export function viteConfigPlugin({ packageRoot }: { packageRoot: string }): Plug
                     ? outDirIsAbsolute
                         ? (buildConfig.outDir as string)
                         : path.resolve(process.cwd(), buildConfig.outDir as string)
-                    : path.resolve(process.cwd(), 'dist/vendure-dashboard');
+                    : path.resolve(process.cwd(), 'dist');
 
                 config.build = {
                     ...buildConfig,

+ 1 - 1
packages/dashboard/vite/vite-plugin-dashboard-metadata.ts

@@ -44,7 +44,7 @@ export function dashboardMetadataPlugin(options: { rootDir: string }): Plugin {
                     export async function runDashboardExtensions() {
                         ${pluginsWithExtensions
                             .map(extension => {
-                                return `await import('${extension}');`;
+                                return `await import(\`${extension}\`);`;
                             })
                             .join('\n')}
                 }`;

+ 40 - 0
packages/dashboard/vite/vite-plugin-transform-index.ts

@@ -0,0 +1,40 @@
+import { Plugin, ResolvedConfig } from 'vite';
+
+/**
+ * @description
+ * This Vite plugin handles the scenario where the `base` path is set in the Vite config.
+ * The default Vite behavior is to prepend the `base` path to all `href` and `src` attributes in the HTML,
+ * but this causes the Vendure Dashboard not to load its assets correctly.
+ *
+ * This plugin removes the `base` path from all `href` and `src` attributes in the HTML,
+ * and adds a `<base>` tag to the `<head>` of the HTML document.
+ */
+export function transformIndexHtmlPlugin(): Plugin {
+    let config: ResolvedConfig;
+    return {
+        name: 'vendure:vite-config-transform-index-html',
+        configResolved(resolvedConfig) {
+            // store the resolved config
+            config = resolvedConfig;
+        },
+        // Only apply this plugin during the build phase
+        apply: 'build',
+        transformIndexHtml(html) {
+            if (config.base && config.base !== '/') {
+                // Remove the base path from hrefs and srcs
+                const basePath = config.base.replace(/\/$/, ''); // Remove trailing slash
+
+                // Single regex to handle both href and src attributes with any quote type
+                const attributeRegex = new RegExp(`(href|src)=(["'])${basePath}/?`, 'g');
+                let transformedHtml = html.replace(attributeRegex, '$1=$2');
+
+                // Add base tag to head
+                const baseTag = `        <base href="${config.base}">\n`;
+                transformedHtml = transformedHtml.replace(/<head>/, `<head>\n${baseTag}`);
+
+                return transformedHtml;
+            }
+            return html;
+        },
+    };
+}

+ 2 - 0
packages/dashboard/vite/vite-plugin-vendure-dashboard.ts

@@ -10,6 +10,7 @@ import { viteConfigPlugin } from './vite-plugin-config.js';
 import { dashboardMetadataPlugin } from './vite-plugin-dashboard-metadata.js';
 import { gqlTadaPlugin } from './vite-plugin-gql-tada.js';
 import { ThemeVariablesPluginOptions, themeVariablesPlugin } from './vite-plugin-theme.js';
+import { transformIndexHtmlPlugin } from './vite-plugin-transform-index.js';
 import { UiConfigPluginOptions, uiConfigPlugin } from './vite-plugin-ui-config.js';
 
 /**
@@ -92,6 +93,7 @@ export function vendureDashboardPlugin(options: VitePluginVendureDashboardOption
         ...(options.gqlTadaOutputPath
             ? [gqlTadaPlugin({ gqlTadaOutputPath: options.gqlTadaOutputPath, tempDir, packageRoot })]
             : []),
+        transformIndexHtmlPlugin(),
     ];
 }
 

+ 1 - 0
packages/dev-server/vite.config.mts

@@ -4,6 +4,7 @@ import { pathToFileURL } from 'url';
 import { defineConfig } from 'vite';
 
 export default defineConfig({
+    base: '/dashboard/',
     plugins: [
         vendureDashboardPlugin({
             vendureConfigPath: pathToFileURL('./dev-config.ts'),