Forráskód Böngészése

Merge branch 'master' into minor

Michael Bromley 8 hónapja
szülő
commit
232b1ab409
100 módosított fájl, 3926 hozzáadás és 844 törlés
  1. 2 1
      .github/workflows/publish_to_npm.yml
  2. 16 0
      CHANGELOG.md
  3. 1 5
      README.md
  4. 4 0
      docs/docs/guides/developer-guide/custom-fields/index.md
  5. 73 0
      docs/docs/guides/developer-guide/importing-data/index.md
  6. 3 1
      docs/docs/guides/developer-guide/migrations/index.md
  7. 4 0
      docs/docs/guides/developer-guide/plugins/index.mdx
  8. 4 4
      docs/docs/guides/extending-the-admin-ui/getting-started/index.md
  9. 3 0
      docs/docusaurus.config.js
  10. 78 0
      docs/src/plugins/llm-txt-plugin.js
  11. 1 1
      lerna.json
  12. 16 0
      license/signatures/version1/cla.json
  13. 4 4
      packages/admin-ui-plugin/package.json
  14. 1 1
      packages/admin-ui-plugin/src/plugin.ts
  15. 3 3
      packages/admin-ui/package.json
  16. 1 1
      packages/admin-ui/src/lib/core/src/common/version.ts
  17. 24 1
      packages/admin-ui/src/lib/core/src/shared/components/modal-dialog/modal-dialog.component.scss
  18. 70 70
      packages/admin-ui/src/lib/static/i18n-messages/ar.json
  19. 29 29
      packages/admin-ui/src/lib/static/i18n-messages/fr.json
  20. 3 3
      packages/asset-server-plugin/package.json
  21. 3 3
      packages/cli/package.json
  22. 1 1
      packages/common/package.json
  23. 34 0
      packages/core/e2e/list-query-builder.e2e-spec.ts
  24. 26 0
      packages/core/e2e/order.e2e-spec.ts
  25. 2 2
      packages/core/package.json
  26. 11 0
      packages/core/src/api/resolvers/admin/draft-order.resolver.ts
  27. 0 1
      packages/core/src/api/resolvers/admin/product.resolver.ts
  28. 8 3
      packages/core/src/config/auth/default-password-validation-strategy.ts
  29. 1 1
      packages/core/src/config/default-config.ts
  30. 1 1
      packages/core/src/plugin/plugin-utils.ts
  31. 29 4
      packages/core/src/service/helpers/list-query-builder/list-query-builder.ts
  32. 13 7
      packages/core/src/service/services/collection.service.ts
  33. 3 3
      packages/create/package.json
  34. 1 1
      packages/dashboard/components.json
  35. 7 6
      packages/dashboard/package.json
  36. 8 8
      packages/dashboard/src/app/app-providers.tsx
  37. 1 1
      packages/dashboard/src/app/main.tsx
  38. 12 1
      packages/dashboard/src/app/routes/_authenticated.tsx
  39. 26 0
      packages/dashboard/src/app/routes/_authenticated/_assets/assets.graphql.ts
  40. 2 2
      packages/dashboard/src/app/routes/_authenticated/_assets/assets.tsx
  41. 156 0
      packages/dashboard/src/app/routes/_authenticated/_assets/assets_.$id.tsx
  42. 104 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/customer-address-selector.tsx
  43. 228 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/edit-order-table.tsx
  44. 18 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/money-gross-net.tsx
  45. 2 1
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-address.tsx
  46. 38 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-line-custom-fields-form.tsx
  47. 53 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-table-totals.tsx
  48. 8 49
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-table.tsx
  49. 65 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/shipping-method-selector.tsx
  50. 187 1
      packages/dashboard/src/app/routes/_authenticated/_orders/orders.graphql.ts
  51. 39 18
      packages/dashboard/src/app/routes/_authenticated/_orders/orders.tsx
  52. 31 9
      packages/dashboard/src/app/routes/_authenticated/_orders/orders_.$id.tsx
  53. 418 0
      packages/dashboard/src/app/routes/_authenticated/_orders/orders_.draft.$id.tsx
  54. 1 1
      packages/dashboard/src/app/routes/_authenticated/_products/products.tsx
  55. 61 0
      packages/dashboard/src/lib/components/data-table/add-filter-menu.tsx
  56. 0 13
      packages/dashboard/src/lib/components/data-table/data-table-column-header.tsx
  57. 75 0
      packages/dashboard/src/lib/components/data-table/data-table-filter-badge.tsx
  58. 27 28
      packages/dashboard/src/lib/components/data-table/data-table-filter-dialog.tsx
  59. 1 0
      packages/dashboard/src/lib/components/data-table/data-table-types.ts
  60. 72 23
      packages/dashboard/src/lib/components/data-table/data-table-view-options.tsx
  61. 23 24
      packages/dashboard/src/lib/components/data-table/data-table.tsx
  62. 57 0
      packages/dashboard/src/lib/components/data-table/filters/data-table-boolean-filter.tsx
  63. 93 0
      packages/dashboard/src/lib/components/data-table/filters/data-table-datetime-filter.tsx
  64. 58 0
      packages/dashboard/src/lib/components/data-table/filters/data-table-id-filter.tsx
  65. 119 0
      packages/dashboard/src/lib/components/data-table/filters/data-table-number-filter.tsx
  66. 62 0
      packages/dashboard/src/lib/components/data-table/filters/data-table-string-filter.tsx
  67. 65 0
      packages/dashboard/src/lib/components/data-table/human-readable-operator.tsx
  68. 4 4
      packages/dashboard/src/lib/components/layout/nav-user.tsx
  69. 0 345
      packages/dashboard/src/lib/components/shared/asset-preview.tsx
  70. 93 0
      packages/dashboard/src/lib/components/shared/asset/asset-focal-point-editor.tsx
  71. 51 20
      packages/dashboard/src/lib/components/shared/asset/asset-gallery.tsx
  72. 1 1
      packages/dashboard/src/lib/components/shared/asset/asset-picker-dialog.tsx
  73. 1 7
      packages/dashboard/src/lib/components/shared/asset/asset-preview-dialog.tsx
  74. 34 0
      packages/dashboard/src/lib/components/shared/asset/asset-preview-selector.tsx
  75. 128 0
      packages/dashboard/src/lib/components/shared/asset/asset-preview.tsx
  76. 46 0
      packages/dashboard/src/lib/components/shared/asset/asset-properties.tsx
  77. 1 1
      packages/dashboard/src/lib/components/shared/asset/focal-point-control.tsx
  78. 4 3
      packages/dashboard/src/lib/components/shared/custom-fields-form.tsx
  79. 13 14
      packages/dashboard/src/lib/components/shared/customer-selector.tsx
  80. 2 2
      packages/dashboard/src/lib/components/shared/detail-page-button.tsx
  81. 3 3
      packages/dashboard/src/lib/components/shared/entity-assets.tsx
  82. 39 0
      packages/dashboard/src/lib/components/shared/navigation-confirmation.tsx
  83. 9 1
      packages/dashboard/src/lib/components/shared/paginated-list-data-table.tsx
  84. 111 0
      packages/dashboard/src/lib/components/shared/product-variant-selector.tsx
  85. 1 1
      packages/dashboard/src/lib/components/shared/vendure-image.tsx
  86. 508 63
      packages/dashboard/src/lib/components/ui/calendar.tsx
  87. 113 3
      packages/dashboard/src/lib/framework/document-introspection/get-document-structure.spec.ts
  88. 70 11
      packages/dashboard/src/lib/framework/document-introspection/get-document-structure.ts
  89. 8 7
      packages/dashboard/src/lib/framework/form-engine/use-generated-form.tsx
  90. 4 0
      packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx
  91. 23 4
      packages/dashboard/src/lib/framework/page/list-page.tsx
  92. 1 0
      packages/dashboard/src/lib/framework/page/use-detail-page.ts
  93. 8 0
      packages/dashboard/src/lib/graphql/fragments.tsx
  94. 5 5
      packages/dashboard/src/lib/index.ts
  95. 12 9
      packages/dashboard/src/lib/providers/auth.tsx
  96. 1 0
      packages/dashboard/src/lib/providers/channel-provider.tsx
  97. 7 1
      packages/dashboard/src/lib/providers/server-config.tsx
  98. 24 0
      packages/dashboard/src/lib/providers/user-settings.tsx
  99. 7 1
      packages/dashboard/vite.config.mts
  100. 4 2
      packages/dashboard/vite/utils/config-loader.ts

+ 2 - 1
.github/workflows/publish_to_npm.yml

@@ -16,7 +16,8 @@ jobs:
         with:
           node-version: '22.x'
           registry-url: 'https://registry.npmjs.org'
-      - run: npm ci
+      # Install  dependencies without modifying package-lock.json file
+      - run: npm install --no-save
       - run: npm run build
       - run: lerna publish from-package --yes
         env:

+ 16 - 0
CHANGELOG.md

@@ -1,3 +1,19 @@
+## <small>3.2.4 (2025-05-05)</small>
+
+
+#### Fixes
+
+* **admin-ui-plugin** Fix issue with sendFile and absolute paths (#3499) ([62664cb](https://github.com/vendure-ecommerce/vendure/commit/62664cb)), closes [#3499](https://github.com/vendure-ecommerce/vendure/issues/3499)
+* **admin-ui-plugin** Fix proxy-middleware v2 to v3 issue (#3507) ([ae20be1](https://github.com/vendure-ecommerce/vendure/commit/ae20be1)), closes [#3507](https://github.com/vendure-ecommerce/vendure/issues/3507)
+* **admin-ui-plugin** proxy target template string (#3514) ([8e6a991](https://github.com/vendure-ecommerce/vendure/commit/8e6a991)), closes [#3514](https://github.com/vendure-ecommerce/vendure/issues/3514)
+* **admin-ui** Relax rxjs dependency version ([01b7375](https://github.com/vendure-ecommerce/vendure/commit/01b7375))
+* **core** Detect circular reference in collection ancestor tree ([c427d8e](https://github.com/vendure-ecommerce/vendure/commit/c427d8e))
+* **core** Enhance password validation strategy with maxLength option and set default maxLength ([fbd3a94](https://github.com/vendure-ecommerce/vendure/commit/fbd3a94))
+* **core** Fix PaginatedList filtering by calculated property ([7966978](https://github.com/vendure-ecommerce/vendure/commit/7966978))
+* **dashboard** Fix several issues related to login/logout ([7b3b917](https://github.com/vendure-ecommerce/vendure/commit/7b3b917))
+* **dashboard** Fix some issues on order list/detail pages ([fe0ceb4](https://github.com/vendure-ecommerce/vendure/commit/fe0ceb4))
+* **dashboard** Improve compatibility of config loading ([7cb3624](https://github.com/vendure-ecommerce/vendure/commit/7cb3624))
+
 ## <small>3.2.3 (2025-04-17)</small>
 
 

+ 1 - 5
README.md

@@ -50,11 +50,7 @@ vendure/
 
 ### 1. Install top-level dependencies
 
-`npm install --legacy-peer-deps`
-
-Note: the `--legacy-peer-deps` flag is necessary because in the dashboard package there is a dependency "react-day-picker" (from Shadcn/ui) which
-has a peer dependency on React v18, whereas we are using v19 in this repo. The latest version of that library is however not currently
-compatible with shadcn (https://github.com/shadcn-ui/ui/issues/4366).
+`npm install`
 
 The root directory has a `package.json` which contains build-related dependencies for tasks including:
 

+ 4 - 0
docs/docs/guides/developer-guide/custom-fields/index.md

@@ -17,6 +17,10 @@ Some use-cases for custom fields include:
 * Storing an external identifier (e.g. from a payment provider) on the `Customer` entity.
 * Adding a longitude and latitude to the `StockLocation` for use in selecting the closest location to a customer.
 
+:::note
+Custom fields are not solely restricted to Vendure's native entities though, it's also possible to add support for custom fields to your own custom entities. See: [Supporting custom fields](/guides/developer-guide/database-entity/#supporting-custom-fields) 
+:::
+
 ## Defining custom fields
 
 Custom fields are specified in the VendureConfig:

+ 73 - 0
docs/docs/guides/developer-guide/importing-data/index.md

@@ -278,6 +278,79 @@ Using these specialized import services is preferable to using the normal servic
 
 However, it is still possible to use the normal service-layer services if you prefer. For example, the following code snippet shows how to create a new ProductVariant using the `ProductVariantService`:
 
+```ts title="src/create-new-variant-service.ts"
+import { INestApplicationContext } from '@nestjs/common';
+import {
+    ProductVariantService,
+    TransactionalConnection,
+    LanguageCode,
+    RequestContext,
+    RequestContextService,
+    bootstrapWorker,
+    ConfigService,
+    ID,
+    User,
+    SearchService,
+} from '@vendure/core';
+
+import { config } from './vendure-config';
+
+async function createNewVariantService() {
+    // We use the bootstrapWorker() function instead of bootstrap() because we don't
+    // need to start the server, we just need access to the services.
+    const { app } = await bootstrapWorker(config);
+
+    // Most service methods require a RequestContext, so we'll create one here.
+    const ctx = await getSuperadminContext(app);
+
+    // Get the ProductVariantService instance from the application
+    const productVariantService = app.get(ProductVariantService);
+
+    // To reindex after importing products
+    const searchService = app.get(SearchService);
+
+    // Example: Creating a new ProductVariant for an existing product with ID 1
+    const productId = '1' as ID;
+    // Create input data for the new variant
+    const variantInput = {
+        productId,
+        translations: [
+            {
+                languageCode: LanguageCode.en,
+                name: 'New Variant 1',
+            },
+        ],
+        sku: 'NEW-VARIANT-001',
+        // Specify additional variant properties...
+    };
+
+    // Create the variant
+    const newVariants = await productVariantService.create(ctx, [variantInput]);
+
+    console.log('Created new product variants:', newVariants);
+
+    // Rebuild search index to include the new variant
+    await searchService.reindex(ctx);
+
+    await app.close();
+}
+
+/**
+ * Creates a RequestContext configured for the default Channel with the activeUser set
+ * as the superadmin user.
+ */
+export async function getSuperadminContext(app: INestApplicationContext): Promise<RequestContext> {
+    const {superadminCredentials} = app.get(ConfigService).authOptions;
+    const superAdminUser = await app.get(TransactionalConnection)
+        .getRepository(User)
+        .findOneOrFail({where: {identifier: superadminCredentials.identifier}});
+    return app.get(RequestContextService).create({
+        apiType: 'admin',
+        user: superAdminUser,
+    });
+}
+```
+
 ## Importing from other platforms
 
 If you are migrating from another platform, you can create a custom import script to import your data into Vendure.

+ 3 - 1
docs/docs/guides/developer-guide/migrations/index.md

@@ -119,10 +119,12 @@ Running the `vendure migrate` command also uses a very similar script internally
 
 ```ts title="migration.ts"
 import { generateMigration, revertLastMigration, runMigrations } from '@vendure/core';
-import program from 'commander';
+import { Command } from 'commander';
 
 import { config } from './src/vendure-config';
 
+const program = new Command();
+
 program
     .command('generate <name>')
     .description('Generate a new migration file with the given name')

+ 4 - 0
docs/docs/guides/developer-guide/plugins/index.mdx

@@ -308,6 +308,10 @@ We can then import this types file in our plugin's main file:
 import './types';
 ```
 
+:::note
+Custom fields are not solely restricted to Vendure's native entities though, it's also possible to add support for custom fields to your own custom entities. This way other plugins would be able to extend our example `WishlistItem`. See: [Supporting custom fields](/guides/developer-guide/database-entity/#supporting-custom-fields) 
+:::
+
 ### Step 4: Create a service
 
 A "service" is a class which houses the bulk of the business logic of any plugin. A plugin can define multiple services if needed, but each service should be responsible for a single unit of functionality, such as dealing with a particular entity, or performing a particular task.

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

@@ -359,20 +359,20 @@ This can then be run from the command line:
 <TabItem value="npm" label="npm" default>
 
 ```bash
-npm run ts-node compile-admin-ui.ts
+npx ts-node src/compile-admin-ui.ts
 ```
 
 </TabItem>
 <TabItem value="yarn" label="yarn">
 
 ```bash
-yarn ts-node compile-admin-ui.ts
+yarn ts-node src/compile-admin-ui.ts
 ```
 
 </TabItem>
 </Tabs>
 
-Once complete, the production-ready app bundle will be output to `admin-ui/dist`. This method is suitable for a production setup, so that the Admin UI can be compiled ahead-of-time as part of your deployment process. This ensures that your Vendure server starts up as quickly as possible. In this case, you can pass the path of the compiled app to the AdminUiPlugin:
+Once complete, the production-ready app bundle will be output to `admin-ui/dist/browser`. This method is suitable for a production setup, so that the Admin UI can be compiled ahead-of-time as part of your deployment process. This ensures that your Vendure server starts up as quickly as possible. In this case, you can pass the path of the compiled app to the AdminUiPlugin:
 
 ```ts title="src/vendure-config.ts"
 import { VendureConfig } from '@vendure/core';
@@ -385,7 +385,7 @@ export const config: VendureConfig = {
         AdminUiPlugin.init({
             port: 3002,
             app: {
-                path: path.join(__dirname, '../admin-ui/dist'),
+                path: path.join(__dirname, '../admin-ui/dist/browser'),
             },
         }),
     ],

+ 3 - 0
docs/docusaurus.config.js

@@ -3,6 +3,7 @@
 
 const lightCodeTheme = require('prism-react-renderer/themes/nightOwlLight');
 const darkCodeTheme = require('prism-react-renderer/themes/nightOwl');
+const llmTxtPlugin = require('./src/plugins/llm-txt-plugin');
 
 /** @type {import('@docusaurus/types').Config} */
 const config = {
@@ -61,6 +62,8 @@ const config = {
     ],
     themes: ['docusaurus-theme-search-typesense'],
 
+    plugins: [llmTxtPlugin],
+
     themeConfig:
         /** @type {import('@docusaurus/preset-classic').ThemeConfig} */
         ({

+ 78 - 0
docs/src/plugins/llm-txt-plugin.js

@@ -0,0 +1,78 @@
+const path = require('path');
+const fs = require('fs');
+
+const DOCS_BASE_URL = process.env.DOCS_BASE_URL || 'https://docs.vendure.io';
+
+module.exports = function (context) {
+    return {
+        name: 'llms-txt-plugin',
+        loadContent: async () => {
+            const { siteDir } = context;
+            const contentDir = siteDir;
+            const allMdx = [];
+
+            // recursive function to get all mdx and md files
+            const getMdxFiles = async dir => {
+                const entries = await fs.promises.readdir(dir, { withFileTypes: true });
+
+                for (const entry of entries) {
+                    const fullPath = path.join(dir, entry.name);
+                    if (entry.isDirectory()) {
+                        await getMdxFiles(fullPath);
+                    } else if (entry.name.endsWith('.mdx') || entry.name.endsWith('.md')) {
+                        const content = await fs.promises.readFile(fullPath, 'utf8');
+                        allMdx.push(content);
+                    }
+                }
+            };
+
+            await getMdxFiles(contentDir);
+            return { allMdx };
+        },
+        postBuild: async ({ content, routes, outDir }) => {
+            const { allMdx } = content;
+
+            // Remove the README.md file of the docs
+            const filteredMdx = allMdx.slice(1);
+
+            // Write concatenated MD and MDX content
+            const concatenatedPath = path.join(outDir, 'llms-full.txt');
+            await fs.promises.writeFile(concatenatedPath, filteredMdx.join('\n\n---\n\n'));
+
+            // we need to dig down several layers:
+            // find PluginRouteConfig marked by plugin.name === "docusaurus-plugin-content-docs"
+            const docsPluginRouteConfig = routes.filter(
+                route => route.plugin.name === 'docusaurus-plugin-content-docs',
+            )[0];
+
+            // docsPluginRouteConfig has a routes property has a record with the path "/" that contains all docs routes.
+            const allDocsRouteConfig = docsPluginRouteConfig.routes?.filter(route => route.path === '/')[0];
+
+            // A little type checking first
+            if (!allDocsRouteConfig?.props?.version) {
+                return;
+            }
+
+            // this route config has a `props` property that contains the current documentation.
+            const currentVersionDocsRoutes = allDocsRouteConfig.props.version.docs;
+
+            // for every single docs route we now parse a path (which is the key) and a title
+            const docsRecords = Object.entries(currentVersionDocsRoutes)
+                .filter(([path, record]) => record.id !== 'TODO')
+                .map(([path, record]) => {
+                    return `- [${record.title}](${DOCS_BASE_URL}/${path}): ${record.description}`;
+                });
+
+            // Build up llms.txt file
+            const llmsTxt = `# ${context.siteConfig.title}\n\n## Docs\n\n${docsRecords.join('\n')}`;
+
+            // Write llms.txt file
+            const llmsTxtPath = path.join(outDir, 'llms.txt');
+            try {
+                fs.writeFileSync(llmsTxtPath, llmsTxt);
+            } catch (err) {
+                throw err;
+            }
+        },
+    };
+};

+ 1 - 1
lerna.json

@@ -1,6 +1,6 @@
 {
     "packages": ["packages/*"],
-    "version": "3.2.3",
+    "version": "3.2.4",
     "npmClient": "npm",
     "command": {
         "version": {

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

@@ -575,6 +575,22 @@
       "created_at": "2025-04-12T13:09:16Z",
       "repoId": 136938012,
       "pullRequestNo": 3477
+    },
+    {
+      "name": "HouseinIsProgramming",
+      "id": 76689341,
+      "comment_id": 2835899547,
+      "created_at": "2025-04-28T16:58:54Z",
+      "repoId": 136938012,
+      "pullRequestNo": 3512
+    },
+    {
+      "name": "baboons",
+      "id": 3737821,
+      "comment_id": 2838315570,
+      "created_at": "2025-04-29T10:55:25Z",
+      "repoId": 136938012,
+      "pullRequestNo": 3499
     }
   ]
 }

+ 4 - 4
packages/admin-ui-plugin/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@vendure/admin-ui-plugin",
-    "version": "3.2.3",
+    "version": "3.2.4",
     "main": "lib/index.js",
     "types": "lib/index.d.ts",
     "files": [
@@ -25,9 +25,9 @@
     "devDependencies": {
         "@types/express": "^5.0.1",
         "@types/fs-extra": "^11.0.4",
-        "@vendure/admin-ui": "3.2.3",
-        "@vendure/common": "3.2.3",
-        "@vendure/core": "3.2.3",
+        "@vendure/admin-ui": "3.2.4",
+        "@vendure/common": "3.2.4",
+        "@vendure/core": "3.2.4",
         "express": "^5.1.0",
         "rimraf": "^5.0.5",
         "typescript": "5.8.2"

+ 1 - 1
packages/admin-ui-plugin/src/plugin.ts

@@ -259,7 +259,7 @@ export class AdminUiPlugin implements NestModule {
         adminUiServer.use(limiter as any);
         adminUiServer.use(express.static(adminUiAppPath));
         adminUiServer.use((req, res) => {
-            res.sendFile(path.join(adminUiAppPath, 'index.html'));
+            res.sendFile('index.html', { root: adminUiAppPath });
         });
 
         return adminUiServer;

+ 3 - 3
packages/admin-ui/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@vendure/admin-ui",
-    "version": "3.2.3",
+    "version": "3.2.4",
     "license": "GPL-3.0-or-later",
     "repository": {
         "type": "git",
@@ -53,7 +53,7 @@
         "@ng-select/ng-select": "^14.2.6",
         "@ngx-translate/core": "^16.0.4",
         "@ngx-translate/http-loader": "^16.0.1",
-        "@vendure/common": "3.2.3",
+        "@vendure/common": "3.2.4",
         "@webcomponents/custom-elements": "^1.6.0",
         "apollo-angular": "^10.0.3",
         "apollo-upload-client": "^18.0.1",
@@ -78,7 +78,7 @@
         "prosemirror-tables": "^1.6.4",
         "react": "^19.0.0",
         "react-dom": "^19.0.0",
-        "rxjs": "7.8.1",
+        "rxjs": "^7.8.1",
         "tslib": "^2.6.2",
         "zone.js": "~0.15.0"
     },

+ 1 - 1
packages/admin-ui/src/lib/core/src/common/version.ts

@@ -1,2 +1,2 @@
 // Auto-generated by the set-version.js script.
-export const ADMIN_UI_VERSION = '3.2.3';
+export const ADMIN_UI_VERSION = '3.2.4';

+ 24 - 1
packages/admin-ui/src/lib/core/src/shared/components/modal-dialog/modal-dialog.component.scss

@@ -17,17 +17,40 @@
         display: flex;
         flex-direction: column;
     }
+
     @media screen and (max-height: 700px) {
         .modal-dialog .modal-content {
             padding: 0.8rem;
         }
-        .modal-header, .modal-header--accessible {
+        .modal-header,
+        .modal-header--accessible {
             padding-bottom: 0.8rem;
         }
         .modal-footer {
             padding-top: 0.8rem;
         }
     }
+
+    // Dark Theme Styles
+    :host-context([data-theme="dark"]) & {
+        .modal-dialog {
+            background-color: var(--color-weight-150);
+            color: var(--color-text-100);
+            border: 1px solid var(--color-component-border-100);
+            box-shadow: 0 0 0 1px var(--color-component-border-200),
+                        0 4px 12px rgba(0, 0, 0, 0.6);
+        }
+
+        .modal-title,
+        .modal-body,
+        .modal-footer {
+            color: var(--color-text-100);
+        }
+
+        button {
+            color: var(--color-button-small-text);
+        }
+    }
 }
 
 .modal-body {

+ 70 - 70
packages/admin-ui/src/lib/static/i18n-messages/ar.json

@@ -421,16 +421,16 @@
     "street-line-1": "خط الشارع 1",
     "street-line-2": "خط الشارع 2",
     "title": "عنوان",
-    "update-customer-group": "تحديث مجموعة العملاء",
+    "update-customer-group": "تحديث مجموعةالحرفاء",
     "verified": "تم التحقق",
     "view-group-members": "عرض أعضاء المجموعة"
   },
   "dashboard": {
     "add-widget": "إضافة القطعة",
     "latest-orders": "أحدث الطلبيات",
-    "metric-average-order-value": "متوسط قيمة الطلب",
-    "metric-number-of-orders": "عدد الطلبيات",
-    "metric-order-total-value": "طلب القيمة الإجمالية",
+    "metric-average-order-value": "مقياس متوسط قيمة الطلب",
+    "metric-number-of-orders": "مقياس عدد الطلبيات",
+    "metric-order-total-value": "مقياس طلب القيمة الإجمالية ",
     "metrics": "مقاييس",
     "orders-summary": "ملخص الطلبيات",
     "remove-widget": "إزالة القطعة",
@@ -439,9 +439,9 @@
     "today": "اليوم",
     "total-order-value": "القيمة الإجمالية",
     "total-orders": "إجمالي الطلبيات",
-    "widget-resize": "تغيير الحجم",
+    "widget-resize": "تغيير حجم القطعة ",
     "widget-width": "العرض: {width}",
-    "yesterday": "أمس"
+    "yesterday": "ليلة الأمس"
   },
   "datetime": {
     "ago-days": "{count, plural, one {يوم واحد مضى} other {{count} أيام مضت}}",
@@ -482,14 +482,14 @@
   "editor": {
     "height": "الارتفاع",
     "image-alt": "الوصف (بديل)",
-    "image-src": "مصدر",
-    "image-title": "عنوان",
+    "image-src": " مصدر الصورة",
+    "image-title": "عنوان الصورة",
     "insert-image": "إدراج صورة",
     "link-href": "رابط الهدف",
     "link-target": "رابط الهدف",
     "link-title": "عنوان الرابط",
-    "remove-link": "إزالة",
-    "set-link": "تعيين الرابط",
+    "remove-link": " إزالةالرابط ",
+    "set-link": "Set Link",
     "width": "العرض"
   },
   "error": {
@@ -502,14 +502,14 @@
   "marketing": {
     "actions": "أجراءات",
     "add-action": "إضافة إجراء",
-    "add-condition": "إضافة حالة",
+    "add-condition": "إضافة شرط",
     "conditions": "شروط",
     "coupon-code": "رمز الكوبون",
     "create-new-promotion": "إنشاء عرض ترويجي جديد",
     "duplicate-promotions": "تكرار الترويجات",
     "ends-at": "ينتهي عند",
     "per-customer-limit": "حد كل عميل",
-    "per-customer-limit-tooltip": "الحد الأقصى لعدد مرات استخدام هذا العرض من قبل عميل واحد",
+    "per-customer-limit-tooltip": "الحد الأقصى لعدد مرات لإستخدام هذا العرض من قبل عميل واحد",
     "promotion": "ترقية",
     "search-by-name-or-coupon-code": "البحث بالاسم أو رمز القسيمة",
     "starts-at": "يبدأ عند",
@@ -539,7 +539,7 @@
     "sellers": "البائعون",
     "settings": "إعدادات",
     "shipping-methods": "طرق الشحن",
-    "stock-locations": "مواقع الأسهم",
+    "stock-locations": "مواقع المدخرات",
     "system": "نظام",
     "system-status": "حالة النظام",
     "tax-categories": "فئات الضرائب",
@@ -569,38 +569,38 @@
     "cancel-selected-items": "إلغاء العناصر المحددة",
     "cancel-specified-items": "إلغاء العناصر المحددة",
     "cancellation-reason": "سبب الإلغاء",
-    "cancelled-order-items-success": "تم إلغاء {count} {count, plural, one {عنصر} other {عناصر}} من الطلب",
-    "cancelled-order-success": "الطلب الذي تم إلغاؤه بنجاح",
-    "complete-draft-order": "إنهاء المسودة",
+    "cancelled-order-items-success": "تم إلغاء { count } { count, plural, one {عنصر} other {عناصر} } من الطلب",
+    "cancelled-order-success": " الطلب تم إلغاؤه بنجاح",
+    "complete-draft-order": "إنهاء مسودةالطلب",
     "confirm-modifications": "تأكيد التعديلات",
     "contents": "محتويات",
     "create-fulfillment": "إنشاء الوفاء",
     "create-fulfillment-success": "خلق الوفاء",
-    "customer": "عميل",
+    "customer": "حريف",
     "delete-draft-order": "حذف المسودة",
     "draft-order": "طلبية مسودة",
-    "edit-billing-address": "تحرير عنوان الفواتير",
-    "edit-shipping-address": "تحرير عنوان الشحن",
-    "error-message": "رسالة خطأ",
+    "edit-billing-address": "تعديل عنوان الفواتير",
+    "edit-shipping-address": "تعديل عنوان الشحن",
+    "error-message": "رسالة خاطأة",
     "existing-address": "العنوان الحالي",
-    "existing-customer": "الزبون الحالي",
-    "filter-is-active": "نشط",
+    "existing-customer": "الحريف الحالي",
+    "filter-is-active": "الفلتر نشط ",
     "fulfill": "بكمل",
-    "fulfill-order": "تنفيذ الأمر",
+    "fulfill-order": "تنفيذ الطلبية",
     "fulfillment": "تحقيق, إنجاز",
     "fulfillment-method": "طريقة الوفاء",
     "history-coupon-code-applied": "رمز القسيمة المطبق",
     "history-coupon-code-removed": "إزالة رمز القسيمة",
-    "history-customer-updated": "تم تحديث المستخدم",
+    "history-customer-updated": "تحديث التاريخ للعميل",
     "history-fulfillment-created": "الإنجاز الذي تم إنشاؤه",
     "history-fulfillment-delivered": "الوفاء بتسليم",
     "history-fulfillment-shipped": "تم شحن الوفاء",
     "history-fulfillment-transition": "تم نقل الوفاء من {من} إلى {إلى}",
     "history-items-cancelled": "{count} {count, plural, one {عنصر} other {عناصر}} تم إلغاؤه",
     "history-order-cancelled": "تم الغاء الأمر او الطلب",
-    "history-order-created": "أجل خلق",
-    "history-order-fulfilled": "أمر تم تحقيقه",
-    "history-order-modified": "ترتيب تعديل",
+    "history-order-created": "تم إنشاء ترتيب التاريخ",
+    "history-order-fulfilled": "الوفاء بالنظام التاريخي",
+    "history-order-modified": "تعديل ترتيب التاريخ ",
     "history-order-transition": "تم نقل الطلب من {من} إلى {إلى}",
     "history-payment-settled": "الدفع المستقر",
     "history-payment-transition": "الدفع #{id} انتقل من {من} إلى {إلى}",
@@ -611,60 +611,60 @@
     "line-fulfillment-partial": "{count} من العناصر {total} تم الوفاء بها",
     "manually-transition-to-state": "الانتقال يدويًا إلى الدولة ...",
     "manually-transition-to-state-message": "نقل الأمر يدويًا إلى دولة أخرى. لاحظ أن حالات النظام تخضع لقواعد قد تمنع بعض التحولات.",
-    "modification-adding-items": "إضافة {count} {count, plural, one {عنصر} other {عناصر}}",
-    "modification-adding-surcharges": "إضافة {count} {count, plural, one {رسوم إضافية} other {رسوم إضافية}}",
-    "modification-adjusting-lines": "ضبط {count} {count, plural, one {سطر} other {أسطر}}",
-    "modification-not-settled": "ليس مستقر",
+    "modification-adding-items": "إضافة {count} {count , الجمع , واحد {item} آخر {العناصر}}",
+    "modification-adding-surcharges": "إضافة {count} {count , الجمع , واحد {رسوم إضافية} {surcharges}}",
+    "modification-adjusting-lines": "ضبط {count} {count , الجمع , واحد {line} آخر {lines}}",
+    "modification-not-settled": " لم يتم تسوية التعديل",
     "modification-recalculate-shipping": "إعادة حساب الشحن",
-    "modification-settled": "تسوية",
+    "modification-settled": "تسوية التعديل",
     "modification-summary": "ملخص التعديلات",
     "modification-updating-billing-address": "تحديث عنوان الفواتير",
     "modification-updating-shipping-address": "تحديث عنوان الشحن",
     "modified-items": "العناصر المعدلة",
-    "modify-order": "تعديل الترتيب",
-    "modify-order-price-difference": "فرق السعر",
+    "modify-order": "تعديل الطلب ",
+    "modify-order-price-difference":  "تعديل فرق سعرالطلب ",
     "net-price": "السعر الصافي",
     "new-customer": "عميل جديد",
     "no-modifications-made": "لم يتم إجراء أي تعديلات",
     "note": "ملحوظة",
     "note-is-private": "ملاحظة خاصة",
-    "note-only-visible-to-administrators": "مرئي للمسؤولين فقط",
-    "note-visible-to-customer": "مرئي للمستعولين والعميل",
+    "note-only-visible-to-administrators": "ملاحظة مرئية فقط للمسؤولين",
+    "note-visible-to-customer": "ملاحظة مرئية للعميل",
     "order": "طلب",
-    "order-history": "تاريخ الطلب",
+    "order-history": "سجل الطلبات",
     "order-is-empty": "الطلب فارغ",
     "order-state-diagram": "ترتيب مخطط الحالة",
     "order-type": "نوع الطلب",
-    "order-type-aggregate": "إجمالي",
-    "order-type-regular": "عادي",
-    "order-type-seller": "تاجر",
+    "order-type-aggregate": "نوع الطلب المجمع",
+    "order-type-regular": "نوع الطلب عادي",
+    "order-type-seller": "نوع الطلب البائع",
     "orders": "طلبيات",
     "original-quantity-at-checkout": "الكمية الأصلية عند الخروج",
-    "payment": "قسط",
+    "payment": "دفع",
     "payment-amount": "مبلغ الدفع",
     "payment-metadata": "بيانات تعريف الدفع",
     "payment-method": "طريقة الدفع او السداد",
     "payment-state": "وضع الأداء",
     "payments": "المدفوعات",
     "placed-at": "وضعت في",
-    "preview-changes": "تغييرات المعاينة",
+    "preview-changes": "معاينة التغييرات ",
     "previous-customer": "العميل السابق",
     "product-name": "اسم المنتج",
     "product-sku": "SKU",
-    "promotions-applied": "العروض الحالية",
-    "prorated-unit-price": "سعر الوحدة المتناسق",
+    "promotions-applied": "العروض الترويجية المطبقة",
+    "prorated-unit-price": "سعر الوحدة التناسبي" ,
     "quantity": "كمية",
     "refund": "استرداد",
     "refund-amount": "مبلغ الاسترداد",
     "refund-and-cancel-order": "استرداد وإلغاء الطلب",
     "refund-cancellation-reason": "سبب الاسترداد/الإلغاء",
     "refund-cancellation-reason-required": "الاسترداد/الإلغاء سبب مطلوب",
-    "refund-metadata": "رد الفوقية المترد",
+    "refund-metadata": "البيانات الوصفية لاسترداد الأموال",
     "refund-order-failed": "فشلت استرداد الأموال",
     "refund-order-success": "ترتيب استرداد بنجاح",
     "refund-reason": "سبب رد المبلغ",
-    "refund-reason-customer-request": "طلب العميل",
-    "refund-reason-not-available": "غير متاح",
+    "refund-reason-customer-request": "سبب استرداد طلب العميل",
+    "refund-reason-not-available": "سبب استرداد غير متاح",
     "refund-shipping": "استرداد الشحن",
     "refund-this-payment": "استرداد هذه الدفعة",
     "refund-total": "إجمالي استرداد الأموال",
@@ -679,15 +679,15 @@
     "select-address": "حدد العنوان",
     "select-shipping-method": "إختر طريقة الشحن",
     "select-state": "اختر الطريقة",
-    "seller-orders": "أوامر البائع",
+    "seller-orders": "طلبات البائع ",
     "set-billing-address": "تعيين عنوان الفواتير",
     "set-coupon-codes": "تعيين رموز القسيمة",
-    "set-customer-for-order": "تعيين العميل",
-    "set-customer-success": "تم تعيين العميل بنجاح",
+    "set-customer-for-order": "تعيين العميل للطلب",
+    "set-customer-success": "تعيين العميل بنجاح",
     "set-fulfillment-state": "مارك كـ {state}",
     "set-shipping-address": "تعيين عنوان الشحن",
     "set-shipping-method": "تعيين طريقة الشحن",
-    "settle-payment": "دفع تسوية",
+    "settle-payment": "تسوية الدفع",
     "settle-payment-error": "لا يمكن تسوية الدفع",
     "settle-payment-success": "دفعت الدفع بنجاح",
     "settle-refund": "تسوية استرداد",
@@ -722,7 +722,7 @@
     "add-products-to-test-order": "إضافة منتجات إلى ترتيب الاختبار",
     "administrator": "مدير",
     "channel": "قناة",
-    "channel-token": "قناة رمز",
+    "channel-token": "رمز القناة المميز",
     "country": "دولة",
     "create-new-channel": "إنشاء قناة جديدة",
     "create-new-country": "إنشاء بلد جديد",
@@ -737,7 +737,7 @@
     "default-role-label": "هذا دور افتراضي ولا يمكن تعديله",
     "default-shipping-zone": "منطقة الشحن الافتراضية",
     "default-tax-zone": "المنطقة الضريبية الافتراضية",
-    "defaults": "التخلف عن السداد",
+    "defaults": "افتراضيات",
     "eligible": "صالح",
     "email-address": "عنوان البريد الإلكتروني",
     "email-address-or-identifier": "عنوان البريد الإلكتروني أو المعرف",
@@ -761,7 +761,7 @@
     "role": "دور",
     "roles": "الأدوار",
     "search-by-product-name-or-sku": "ابحث باسم المنتج أو SKU",
-    "seller": "تاجر",
+    "seller": "البائع",
     "shipping-calculator": "حاسبة الشحن",
     "shipping-eligibility-checker": "مدقق الأهلية الشحن",
     "shipping-method": "طريقة الشحن",
@@ -782,7 +782,7 @@
     "authorized": "مخول",
     "cancelled": "ألغيت",
     "created": "مخلوق",
-    "declined": "انخفض",
+    "declined": "مرفوض",
     "delivered": "تم التوصيل",
     "draft": "مسودة",
     "error": "خطأ",
@@ -800,25 +800,25 @@
     "all-job-queues": "جميع قوائم انتظار الوظائف",
     "could-not-trigger-task": "لم يتم تنفيذ المهمة",
     "health-all-systems-up": "جميع الأنظمة",
-    "health-error": "خطأ: نظام واحد أو أكثر من الانخفاض!",
-    "health-last-checked": "آخر فحص",
-    "health-message": "رسالة",
-    "health-refresh": "ينعش",
-    "health-status": "حالة",
-    "health-status-down": "تحت",
-    "health-status-up": "أعلى",
+    "health-error": "خطأ صحي",
+    "health-last-checked": "آخر فحص للصحة ",
+    "health-message": "رسالة تتعلق بالصحة",
+    "health-refresh": "تحديث الصحة",
+    "health-status": "الحالة الصحية",
+    "health-status-down": "الحالة الصحية منخفضة",
+    "health-status-up": "الحالة الصحية أعلى",
     "job-data": "بيانات الوظيفة",
-    "job-duration": "مدة",
+    "job-duration": "مدة الوظيفة",
     "job-error": "خطأ في الوظيفة",
     "job-queue-name": "اسم قائمة الانتظار",
     "job-result": "نتيجة الوظيفة",
-    "job-state": "دولة الوظيفة",
+    "job-state": "حالة الوظيفة",
     "job-state-all": "جميع الحالات",
-    "job-state-cancelled": "تم الإلغاء",
-    "job-state-completed": "تمت",
-    "job-state-failed": "فشل",
-    "job-state-pending": "قيد الانتظار",
-    "job-state-running": "قيد التشغيل",
+    "job-state-cancelled": "تم إلغاء حالة الوظيفة",
+    "job-state-completed": "اكتملت حالة الوظيفة",
+    "job-state-failed": "فشل حالة الوظيفة",
+    "job-state-pending": "حالة الوظيفة معلقة",
+    "job-state-running": "تشغيل حالة الوظيفة",
     "last-executed-at": "آخر تنفيذ",
     "last-result": "آخر نتيجة",
     "next-execution-at": "التالي",
@@ -827,4 +827,4 @@
     "task-id": "معرف المهمة",
     "task-will-be-triggered": "سيتم تنفيذ المهمة"
   }
-}
+}

+ 29 - 29
packages/admin-ui/src/lib/static/i18n-messages/fr.json

@@ -418,8 +418,8 @@
     "select-customer": "Sélectionner client",
     "set-as-default-billing-address": "Etablir en tant qu'adresse de facturation par défaut",
     "set-as-default-shipping-address": "Etablir en tant qu'adresse de livraison par défaut",
-    "street-line-1": "Rue ligne 1",
-    "street-line-2": "Rue ligne 2",
+    "street-line-1": "Ligne de rue  1",
+    "street-line-2": "Ligne de rue 2",
     "title": "Titre",
     "update-customer-group": "Mettre à jour groupe de client",
     "verified": "Verifié",
@@ -429,16 +429,16 @@
     "add-widget": "Ajouter widget",
     "latest-orders": "Dernières commandes",
     "metric-average-order-value": "Valeur moyenne des commandes",
-    "metric-number-of-orders": "Nb commandes",
+    "metric-number-of-orders": "Nombre de  commandes",
     "metric-order-total-value": "Total des commandes",
     "metrics": "Statistiques",
     "orders-summary": "Résumé des commandes",
-    "remove-widget": "Supperimer widget",
+    "remove-widget": "Supprimer widget",
     "thisMonth": "Ce mois-ci",
     "thisWeek": "Cette semaine",
     "today": "Aujourd'hui",
     "total-order-value": "Valeur totale",
-    "total-orders": "Total commandes",
+    "total-orders": "Toutes les commandes",
     "widget-resize": "Redimensionner",
     "widget-width": "Largeur: {width}",
     "yesterday": "Hier"
@@ -547,15 +547,15 @@
     "zones": "Zones"
   },
   "order": {
-    "add-item-to-order": "Ajouter élément à la commande",
+    "add-item-to-order": "Ajouter un article à la commande",
     "add-note": "Ajouter une note",
     "add-payment": "Ajouter un moyen de paiement",
     "add-payment-to-order": "Ajouter un moyen de paiement à la commande",
     "add-payment-to-order-success": "Ajout du paiement à la commande réussi",
-    "add-surcharge": "Ajouter surcharge",
+    "add-surcharge": "Ajouter une majoration",
     "added-items": "Ajouter des éléments",
     "amount": "Quantité",
-    "arrange-additional-payment": "Arranger un paiment additionnel",
+    "arrange-additional-payment": "Organiser un paiement supplémentaire",
     "assign-order-to-another-customer": "Attribuer la commande à un autre client",
     "billing-address": "Adresse de facturation",
     "cancel": "Annuler",
@@ -563,7 +563,7 @@
     "cancel-fulfillment": "Annuler préparation",
     "cancel-modification": "Annuler la modification",
     "cancel-order": "Annuler la commande",
-    "cancel-payment": "Annuler le paiment",
+    "cancel-payment": "Annuler le paiement",
     "cancel-reason-customer-request": "Demande du client",
     "cancel-reason-not-available": "Pas disponible",
     "cancel-selected-items": "Annuler les articles selectionnés",
@@ -574,7 +574,7 @@
     "complete-draft-order": "Valider le brouillon",
     "confirm-modifications": "Confirmer les modifications",
     "contents": "Contenu",
-    "create-fulfillment": "Créer préparation",
+    "create-fulfillment": "Créer une préparation",
     "create-fulfillment-success": "Préparation créée",
     "customer": "Client",
     "delete-draft-order": "Supprimer le brouillon",
@@ -585,7 +585,7 @@
     "existing-address": "Adresse existante",
     "existing-customer": "Client existant",
     "filter-is-active": "Le filtre est actif",
-    "fulfill": "Préparer",
+    "fulfill": "Effectuer la préparation",
     "fulfill-order": "Préparer la commande",
     "fulfillment": "Préparation",
     "fulfillment-method": "Méthode de préparation",
@@ -606,12 +606,12 @@
     "history-payment-transition": "paiement #{id} transféré de {from} à {to}",
     "history-refund-transition": "Remboursement #{id} transféré de {from} à {to}",
     "item-count": "{count} {count, plural, one {article} other {articles}}",
-    "line-fulfillment-all": "Tous les articles préparés",
+    "line-fulfillment-all": "Tous les articles ont été préparés",
     "line-fulfillment-none": "Aucun article préparé",
     "line-fulfillment-partial": "{ count } sur { total } {count, plural, one {article préparé} other {articles préparés}}",
     "manually-transition-to-state": "Changer manuellement l'état de la commande...",
     "manually-transition-to-state-message": "Changer manuellement la commande d'état. A noter que les états sont régis par dès règles qui peuvent empêcher certaines transitions.",
-    "modification-adding-items": "Ajout de {count} {count, plural, one {article} other {articles}}",
+    "modification-adding-items": "Ajout de {count, plural, one {1 article} other {{count} articles}}",
     "modification-adding-surcharges": "Ajout de {count} {count, plural, one {surcharge} other {surcharges}}",
     "modification-adjusting-lines": "Ajustement de {count} {count, plural, one {line} other {lines}}",
     "modification-not-settled": "Non établi",
@@ -629,7 +629,7 @@
     "note": "Note",
     "note-is-private": "La note est privée",
     "note-only-visible-to-administrators": "Visible par les admins uniquement",
-    "note-visible-to-customer": "Visible par les admins et le client",
+    "note-visible-to-customer": "Visible par les administrateurs et le client",
     "order": "Commande",
     "order-history": "Historique de la commande",
     "order-is-empty": "La commande est vide",
@@ -680,7 +680,7 @@
     "select-shipping-method": "Sélectionner méthode transport",
     "select-state": "Sélectionner un état",
     "seller-orders": "Commandes du vendeur",
-    "set-billing-address": "Définir adresse de livraison",
+    "set-billing-address": "Définir l'adresse de facturation",
     "set-coupon-codes": "Définir les codes promo",
     "set-customer-for-order": "Définir le client pour la commande",
     "set-customer-success": "Client défini avec succès",
@@ -696,25 +696,25 @@
     "shipping": "Expédition",
     "shipping-address": "Adresse de livraison",
     "shipping-cancelled": "Livraison annulée",
-    "shipping-method": "Mode de livraison",
+    "shipping-method": "Méthode de livraison",
     "state": "État",
     "sub-total": "Sous-total",
     "successfully-updated-fulfillment": "Préparation mise à jour",
     "surcharges": "Surcharges",
     "tax-base": "Prix HT",
     "tax-description": "Description de la taxe",
-    "tax-rate": "Taux de taxe",
+    "tax-rate": "Taux de TVA",
     "tax-summary": "Résumé de la taxe",
-    "tax-total": "Total TTC",
+    "tax-total": "Montant total des taxes",
     "total": "Total",
     "tracking-code": "Numéro de suivi",
     "transaction-id": "Numéro de transaction",
-    "transition-to-state": "Passage à l'état: { state }",
+    "transition-to-state": "Passer à l'état : { state }",
     "transitioned-payment-to-state-success": "Transition du paiement vers l'état { state } réussi",
     "transitioned-to-state-success": "Passage à l'état { state } avec succés",
     "unable-to-transition-to-state-try-another": "La commande n'a pas pu être remise à l'état \"{state}\" . Veuillez-sélectionner un autre état.",
     "unfulfilled": "Non préparé",
-    "unit-price": "Prix à l'unité"
+    "unit-price": "Prix unitaire"
   },
   "settings": {
     "add-countries-to-zone": "Ajouter des pays à { zoneName }",
@@ -725,13 +725,13 @@
     "channel-token": "Jeton de canal",
     "country": "Pays",
     "create-new-channel": "Créer nouveau canal",
-    "create-new-country": "Créer nouveau pays",
+    "create-new-country": "Créer un nouveau pays",
     "create-new-payment-method": "Créer une nouvelle méthode de paiement",
-    "create-new-role": "Créer nouveau role",
-    "create-new-seller": "Créer nouveau vendeur",
-    "create-new-shipping-method": "Créer nouveau mode d'expédition",
+    "create-new-role": "Créer un nouveau rôle",
+    "create-new-seller": "Créer un nouveau vendeur",
+    "create-new-shipping-method": "Créer une nouvelle méthode d'expédition",
     "create-new-tax-category": "Créer catégorie de taxe",
-    "create-new-tax-rate": "Créer nouveau taux de taxe",
+    "create-new-tax-rate": "Créer un nouveau taux de TVA",
     "create-new-zone": "Créer nouvelle zone",
     "default-currency": "Devise par défaut",
     "default-role-label": "Ceci est le role par défaut et ne peut pas être modifié",
@@ -745,7 +745,7 @@
     "fulfillment-handler": "Gestionnaire de remplissage",
     "global-available-languages-tooltip": "Définit les langues disponibles pour tous les canaux. Les canaux individuels peuvent alors prendre en charge un sous-ensemble de ces langues.",
     "global-out-of-stock-threshold": "Limite de rupture de stock globale",
-    "global-out-of-stock-threshold-tooltip": "Régler le niveau de stock à partir duquel la variante est considéré en rupture de stock. Renseigner une valeur négative permet d'accepter des commandes en attente. La valeur peut être régler individuellement par variante de produit.",
+    "global-out-of-stock-threshold-tooltip": "Définir le seuil de stock à partir duquel une variante est considérée comme en rupture. Une valeur négative autorise les commandes en attente.",
     "last-name": "Nom",
     "no-eligible-shipping-methods": "Aucun mode d'expédition",
     "password": "Mot de passe",
@@ -757,13 +757,13 @@
     "profile": "Profil",
     "rate": "Taux",
     "remove-countries-from-zone-success": "{ countryCount } {countryCount, plural, one {pays retiré} other {pays retirés}} de la zone \"{ zoneName }\"",
-    "remove-from-zone": "Retirer de la zone",
+    "remove-from-zone": "Supprimer de la zone",
     "role": "Role",
     "roles": "Roles",
     "search-by-product-name-or-sku": "Chercher par nom de produit ou par UGS",
     "seller": "Vendeur",
     "shipping-calculator": "Calculateur de frais d'expédition",
-    "shipping-eligibility-checker": "Controleur d'égibilité de livraison",
+    "shipping-eligibility-checker": "Vérificateur d'éligibilité à l'expédition",
     "shipping-method": "Mode d'expédition",
     "tax-category": "Catégorie de taxe",
     "tax-rate": "Taux de taxe",
@@ -771,7 +771,7 @@
     "test-result": "Résultat de test",
     "test-shipping-method": "Mode d'expédition de test",
     "test-shipping-methods": "Modes d'expédition de test",
-    "track-inventory-default": "Suivre l'inventaire par défaut",
+    "track-inventory-default": "Suivre le stock par défaut",
     "view-zone-members": "Voir les membres",
     "zone": "Zone"
   },

+ 3 - 3
packages/asset-server-plugin/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@vendure/asset-server-plugin",
-    "version": "3.2.3",
+    "version": "3.2.4",
     "main": "lib/index.js",
     "types": "lib/index.d.ts",
     "files": [
@@ -30,8 +30,8 @@
         "@types/express": "^5.0.1",
         "@types/fs-extra": "^11.0.4",
         "@types/node-fetch": "^2.6.11",
-        "@vendure/common": "3.2.3",
-        "@vendure/core": "3.2.3",
+        "@vendure/common": "3.2.4",
+        "@vendure/core": "3.2.4",
         "express": "^5.1.0",
         "node-fetch": "^2.7.0",
         "rimraf": "^5.0.5",

+ 3 - 3
packages/cli/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@vendure/cli",
-    "version": "3.2.3",
+    "version": "3.2.4",
     "description": "A modern, headless ecommerce framework",
     "repository": {
         "type": "git",
@@ -35,7 +35,7 @@
     ],
     "dependencies": {
         "@clack/prompts": "^0.7.0",
-        "@vendure/common": "3.2.3",
+        "@vendure/common": "3.2.4",
         "change-case": "^4.1.2",
         "commander": "^11.0.0",
         "dotenv": "^16.4.5",
@@ -46,7 +46,7 @@
         "tsconfig-paths": "^4.2.0"
     },
     "devDependencies": {
-        "@vendure/core": "3.2.3",
+        "@vendure/core": "3.2.4",
         "typescript": "5.8.2"
     }
 }

+ 1 - 1
packages/common/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@vendure/common",
-    "version": "3.2.3",
+    "version": "3.2.4",
     "main": "index.js",
     "license": "GPL-3.0-or-later",
     "repository": {

+ 34 - 0
packages/core/e2e/list-query-builder.e2e-spec.ts

@@ -1115,6 +1115,23 @@ describe('ListQueryBuilder', () => {
             expect(getItemLabels(testEntities.items)).toEqual(['A', 'B']);
         });
 
+        it('filter by calculated property with boolean expression', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    filter: {
+                        _and: [
+                            {
+                                descriptionLength: {
+                                    lt: 12,
+                                },
+                            },
+                        ],
+                    },
+                },
+            });
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B']);
+        });
+
         it('filter by calculated property with join', async () => {
             const { testEntities } = await adminClient.query(GET_LIST, {
                 options: {
@@ -1128,6 +1145,23 @@ describe('ListQueryBuilder', () => {
             expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'E']);
         });
 
+        it('filter by calculated property with join and boolean expression', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    filter: {
+                        _and: [
+                            {
+                                price: {
+                                    lt: 14,
+                                },
+                            },
+                        ],
+                    },
+                },
+            });
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'E']);
+        });
+
         it('sort by simple calculated property', async () => {
             const { testEntities } = await adminClient.query(GET_LIST, {
                 options: {

+ 26 - 0
packages/core/e2e/order.e2e-spec.ts

@@ -221,6 +221,32 @@ describe('Orders resolver', () => {
             expect(result.orders.items.map(o => o.id).sort()).toEqual(['T_1', 'T_2']);
         });
 
+        it('filtering by total', async () => {
+            const result = await adminClient.query<
+                Codegen.GetOrderListQuery,
+                Codegen.GetOrderListQueryVariables
+            >(GET_ORDERS_LIST, {
+                options: {
+                    filter: { total: { gt: 1000_00 } },
+                },
+            });
+            expect(result.orders.items.map(o => o.id).sort()).toEqual(['T_1', 'T_2']);
+        });
+
+        it('filtering by total using boolean expression', async () => {
+            const result = await adminClient.query<
+                Codegen.GetOrderListQuery,
+                Codegen.GetOrderListQueryVariables
+            >(GET_ORDERS_LIST, {
+                options: {
+                    filter: {
+                        _and: [{ total: { gt: 1000_00 } }],
+                    },
+                },
+            });
+            expect(result.orders.items.map(o => o.id).sort()).toEqual(['T_1', 'T_2']);
+        });
+
         it('order', async () => {
             const result = await adminClient.query<Codegen.GetOrderQuery, Codegen.GetOrderQueryVariables>(
                 GET_ORDER,

+ 2 - 2
packages/core/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@vendure/core",
-    "version": "3.2.3",
+    "version": "3.2.4",
     "description": "A modern, headless ecommerce framework",
     "repository": {
         "type": "git",
@@ -49,7 +49,7 @@
         "@nestjs/platform-express": "~11.0.12",
         "@nestjs/terminus": "~11.0.0",
         "@nestjs/typeorm": "~11.0.0",
-        "@vendure/common": "3.2.3",
+        "@vendure/common": "3.2.4",
         "bcrypt": "^5.1.1",
         "body-parser": "^1.20.2",
         "cookie-session": "^2.1.0",

+ 11 - 0
packages/core/src/api/resolvers/admin/draft-order.resolver.ts

@@ -24,6 +24,7 @@ import {
     Permission,
     QueryEligibleShippingMethodsForDraftOrderArgs,
     ShippingMethodQuote,
+    MutationSetDraftOrderCustomFieldsArgs,
 } from '@vendure/common/lib/generated-types';
 
 import { ErrorResultUnion, isGraphQlErrorResult } from '../../../common/error/error-result';
@@ -125,6 +126,16 @@ export class DraftOrderResolver {
         return this.orderService.removeItemFromOrder(ctx, args.orderId, args.orderLineId);
     }
 
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.CreateOrder)
+    async setDraftOrderCustomFields(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationSetDraftOrderCustomFieldsArgs,
+    ): Promise<ErrorResultUnion<RemoveOrderItemsResult, Order>> {
+        return this.orderService.updateCustomFields(ctx, args.orderId, args.input.customFields ?? {});
+    }
+
     @Transaction()
     @Mutation()
     @Allow(Permission.CreateOrder)

+ 0 - 1
packages/core/src/api/resolvers/admin/product.resolver.ts

@@ -135,7 +135,6 @@ export class ProductResolver {
         @Ctx() ctx: RequestContext,
         @Args() args: MutationUpdateProductsArgs,
     ): Promise<Array<Translated<Product>>> {
-        const { input } = args;
         return await Promise.all(args.input.map(i => this.productService.update(ctx, i)));
     }
 

+ 8 - 3
packages/core/src/config/auth/default-password-validation-strategy.ts

@@ -5,7 +5,7 @@ import { PasswordValidationStrategy } from './password-validation-strategy';
 /**
  * @description
  * The DefaultPasswordValidationStrategy allows you to specify a minimum length and/or
- * a regular expression to match passwords against.
+ * a regular expression to match passwords against. The default `maxLength` is `72`.
  *
  * TODO:
  * By default, the `minLength` will be set to `4`. This is rather permissive and is only
@@ -16,15 +16,20 @@ import { PasswordValidationStrategy } from './password-validation-strategy';
  * @since 1.5.0
  */
 export class DefaultPasswordValidationStrategy implements PasswordValidationStrategy {
-    constructor(private options: { minLength?: number; regexp?: RegExp }) {}
+    constructor(private options: { minLength?: number; maxLength?: number; regexp?: RegExp }) {}
 
     validate(ctx: RequestContext, password: string): boolean | string {
-        const { minLength, regexp } = this.options;
+        const { minLength, maxLength, regexp } = this.options;
         if (minLength != null) {
             if (password.length < minLength) {
                 return false;
             }
         }
+        if (maxLength != null) {
+            if (password.length > maxLength) {
+                return false;
+            }
+        }
         if (regexp != null) {
             if (!regexp.test(password)) {
                 return false;

+ 1 - 1
packages/core/src/config/default-config.ts

@@ -112,7 +112,7 @@ export const defaultConfig: RuntimeVendureConfig = {
         adminAuthenticationStrategy: [new NativeAuthenticationStrategy()],
         customPermissions: [],
         passwordHashingStrategy: new BcryptPasswordHashingStrategy(),
-        passwordValidationStrategy: new DefaultPasswordValidationStrategy({ minLength: 4 }),
+        passwordValidationStrategy: new DefaultPasswordValidationStrategy({ minLength: 4, maxLength: 72 }),
         verificationTokenStrategy: new DefaultVerificationTokenStrategy(),
     },
     catalogOptions: {

+ 1 - 1
packages/core/src/plugin/plugin-utils.ts

@@ -39,7 +39,7 @@ export function createProxyHandler(options: ProxyOptions): RequestHandler {
     const proxyHostname = options.hostname || 'localhost';
     const middleware = createProxyMiddleware({
         // TODO: how do we detect https?
-        target: `http://${proxyHostname}:${options.port}`,
+        target: `http://${proxyHostname}:${options.port}/${options.basePath ?? ''}`,
         pathRewrite: {
             [`^${route}`]: '/' + (options.basePath || ''),
         },

+ 29 - 4
packages/core/src/service/helpers/list-query-builder/list-query-builder.ts

@@ -505,10 +505,7 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         options: ListQueryOptions<T>,
     ) {
         const calculatedColumns = getCalculatedColumns(entity);
-        const filterAndSortFields = unique([
-            ...Object.keys(options.filter || {}),
-            ...Object.keys(options.sort || {}),
-        ]);
+        const filterAndSortFields = this.getFilterAndSortFields(options);
         const alias = getEntityAlias(this.connection.rawConnection, entity);
         for (const field of filterAndSortFields) {
             const calculatedColumnDef = calculatedColumns.find(c => c.name === field);
@@ -534,6 +531,34 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         }
     }
 
+    private getFilterAndSortFields<T extends VendureEntity>(options: ListQueryOptions<T>): string[] {
+        const sortFields = Object.keys(options.sort || {});
+        // filter fields can be immediate children of the filter object
+        // or nested inside _and or _or
+        const filterFields = this.getFilterFields(options.filter);
+        return unique([...sortFields, ...filterFields]);
+    }
+
+    private getFilterFields<T extends VendureEntity>(
+        filter?: NullOptionals<FilterParameter<T>> | null,
+    ): string[] {
+        if (!filter) {
+            return [];
+        }
+        const filterFields: string[] = [];
+        for (const key in filter) {
+            if (key === '_and' || key === '_or') {
+                const value = filter[key] as Array<FilterParameter<T>>;
+                for (const condition of value) {
+                    filterFields.push(...this.getFilterFields(condition));
+                }
+            } else if (filter[key as keyof FilterParameter<T>]) {
+                filterFields.push(key);
+            }
+        }
+        return unique(filterFields);
+    }
+
     /**
      * @description
      * If this entity is Translatable, and we are sorting on one of the translatable fields,

+ 13 - 7
packages/core/src/service/services/collection.service.ts

@@ -177,7 +177,7 @@ export class CollectionService implements OnModuleInit {
                             // 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),
+                                    new CollectionModificationEvent(ctx, collection, chunk),
                                 ),
                             );
                         }
@@ -412,6 +412,12 @@ export class CollectionService implements OnModuleInit {
                 .loadOne();
             if (parent) {
                 if (!parent.isRoot) {
+                    if (idsAreEqual(parent.id, id)) {
+                        Logger.error(
+                            `Circular reference detected in Collection tree: Collection ${id} is its own parent`,
+                        );
+                        return _ancestors;
+                    }
                     _ancestors.push(parent);
                     return getParent(parent.id, _ancestors);
                 }
@@ -464,8 +470,8 @@ export class CollectionService implements OnModuleInit {
         for (const filterType of collectionFilters) {
             const filtersOfType = applicableFilters.filter(f => f.code === filterType.code);
             if (filtersOfType.length) {
-                for (const filter of filtersOfType) {
-                    qb = filterType.apply(qb, filter.args);
+                for (const collectionFilter of filtersOfType) {
+                    qb = filterType.apply(qb, collectionFilter.args);
                 }
             }
         }
@@ -654,8 +660,8 @@ export class CollectionService implements OnModuleInit {
     ): ConfigurableOperation[] {
         const filters: ConfigurableOperation[] = [];
         if (input.filters) {
-            for (const filter of input.filters) {
-                filters.push(this.configArgService.parseInput('CollectionFilter', filter));
+            for (const filterInput of input.filters) {
+                filters.push(this.configArgService.parseInput('CollectionFilter', filterInput));
             }
         }
         return filters;
@@ -699,8 +705,8 @@ export class CollectionService implements OnModuleInit {
         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);
+                for (const collectionFilter of filtersOfType) {
+                    filteredQb = filterType.apply(filteredQb, collectionFilter.args);
                 }
             }
         }

+ 3 - 3
packages/create/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@vendure/create",
-    "version": "3.2.3",
+    "version": "3.2.4",
     "license": "GPL-3.0-or-later",
     "bin": {
         "create": "./index.js"
@@ -31,14 +31,14 @@
         "@types/fs-extra": "^11.0.4",
         "@types/handlebars": "^4.1.0",
         "@types/semver": "^7.5.8",
-        "@vendure/core": "3.2.3",
+        "@vendure/core": "3.2.4",
         "rimraf": "^5.0.5",
         "ts-node": "^10.9.2",
         "typescript": "5.8.2"
     },
     "dependencies": {
         "@clack/prompts": "^0.7.0",
-        "@vendure/common": "3.2.3",
+        "@vendure/common": "3.2.4",
         "commander": "^11.0.0",
         "cross-spawn": "^7.0.3",
         "fs-extra": "^11.2.0",

+ 1 - 1
packages/dashboard/components.json

@@ -8,7 +8,7 @@
     "css": "src/styles.css",
     "baseColor": "neutral",
     "cssVariables": true,
-    "prefix": "",
+    "prefix": ""
   },
   "aliases": {
     "components": "@/components",

+ 7 - 6
packages/dashboard/package.json

@@ -1,7 +1,7 @@
 {
     "name": "@vendure/dashboard",
     "private": false,
-    "version": "3.2.3",
+    "version": "3.2.4",
     "type": "module",
     "repository": {
         "type": "git",
@@ -46,6 +46,7 @@
     ],
     "dependencies": {
         "@dnd-kit/core": "^6.3.1",
+        "@dnd-kit/modifiers": "^9.0.0",
         "@dnd-kit/sortable": "^10.0.0",
         "@hookform/resolvers": "^4.1.3",
         "@lingui/babel-plugin-lingui-macro": "^5.2.0",
@@ -85,8 +86,8 @@
         "@types/react-dom": "^19.0.4",
         "@types/react-grid-layout": "^1.3.5",
         "@uidotdev/usehooks": "^2.4.1",
-        "@vendure/common": "3.2.3",
-        "@vendure/core": "3.2.3",
+        "@vendure/common": "3.2.4",
+        "@vendure/core": "3.2.4",
         "@vitejs/plugin-react": "^4.3.4",
         "awesome-graphql-client": "^2.1.0",
         "class-variance-authority": "^0.7.1",
@@ -100,7 +101,7 @@
         "motion": "^12.6.2",
         "next-themes": "^0.4.6",
         "react": "^19.0.0",
-        "react-day-picker": "^8.10.1",
+        "react-day-picker": "^9.6.7",
         "react-dom": "^19.0.0",
         "react-dropzone": "^14.3.8",
         "react-grid-layout": "^1.5.1",
@@ -110,10 +111,10 @@
         "tailwind-merge": "^3.0.1",
         "tailwindcss": "^4.0.6",
         "tailwindcss-animate": "^1.0.7",
+        "tsconfig-paths": "^4.2.0",
         "tw-animate-css": "^1.2.4",
         "vite": "^6.1.0",
-        "zod": "^3.24.2",
-        "tsconfig-paths": "^4.2.0"
+        "zod": "^3.24.2"
     },
     "devDependencies": {
         "@eslint/js": "^9.19.0",

+ 8 - 8
packages/dashboard/src/app/app-providers.tsx

@@ -14,15 +14,15 @@ export function AppProviders({ children }: { children: React.ReactNode }) {
     return (
         <I18nProvider>
             <QueryClientProvider client={queryClient}>
-                <ServerConfigProvider>
-                    <UserSettingsProvider>
-                        <ThemeProvider defaultTheme="system">
-                            <AuthProvider>
+                <UserSettingsProvider>
+                    <ThemeProvider defaultTheme="system">
+                        <AuthProvider>
+                            <ServerConfigProvider>
                                 <ChannelProvider>{children}</ChannelProvider>
-                            </AuthProvider>
-                        </ThemeProvider>
-                    </UserSettingsProvider>
-                </ServerConfigProvider>
+                            </ServerConfigProvider>
+                        </AuthProvider>
+                    </ThemeProvider>
+                </UserSettingsProvider>
                 <ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
             </QueryClientProvider>
         </I18nProvider>

+ 1 - 1
packages/dashboard/src/app/main.tsx

@@ -46,7 +46,7 @@ function InnerApp() {
         }
         setCustomFieldsMap(serverConfig.entityCustomFields);
         setHasSetCustomFieldsMap(true);
-    }, [serverConfig?.entityCustomFields]);
+    }, [serverConfig?.entityCustomFields.length]);
 
     return (
         <>

+ 12 - 1
packages/dashboard/src/app/routes/_authenticated.tsx

@@ -1,7 +1,8 @@
 import { AppLayout } from '@/components/layout/app-layout.js';
-import { createFileRoute, redirect } from '@tanstack/react-router';
+import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router';
 import { AUTHENTICATED_ROUTE_PREFIX } from '@/constants.js';
 import * as React from 'react';
+import { useAuth } from '@/hooks/use-auth.js';
 
 export const Route = createFileRoute(AUTHENTICATED_ROUTE_PREFIX)({
     beforeLoad: ({ context, location }) => {
@@ -21,5 +22,15 @@ export const Route = createFileRoute(AUTHENTICATED_ROUTE_PREFIX)({
 });
 
 function AuthLayout() {
+    const navigate = useNavigate();
+    const { isAuthenticated } = useAuth();
+
+    if (!isAuthenticated) {
+        navigate({
+            to: '/login'
+        });
+        return <></>;
+    }
+
     return <AppLayout />;
 }

+ 26 - 0
packages/dashboard/src/app/routes/_authenticated/_assets/assets.graphql.ts

@@ -0,0 +1,26 @@
+import { assetFragment } from '@/graphql/fragments.js';
+import { graphql } from '@/graphql/graphql.js';
+
+export const assetDetailDocument = graphql(
+    `
+        query AssetDetail($id: ID!) {
+            asset(id: $id) {
+                ...Asset
+                tags {
+                    id
+                    value
+                }
+                customFields
+            }
+        }
+    `,
+    [assetFragment],
+);
+
+export const assetUpdateDocument = graphql(`
+    mutation AssetUpdate($input: UpdateAssetInput!) {
+        updateAsset(input: $input) {
+            id
+        }
+    }
+`);

+ 2 - 2
packages/dashboard/src/app/routes/_authenticated/_assets/assets.tsx

@@ -1,4 +1,4 @@
-import { AssetGallery } from '@/components/shared/asset-gallery.js';
+import { AssetGallery } from '@/components/shared/asset/asset-gallery.js';
 import { Page, PageTitle, PageActionBar } from '@/framework/layout-engine/page-layout.js';
 import { Trans } from '@/lib/trans.js';
 import { createFileRoute } from '@tanstack/react-router';
@@ -13,7 +13,7 @@ function RouteComponent() {
             <PageTitle>
                 <Trans>Assets</Trans>
             </PageTitle>
-            <AssetGallery selectable={false} />
+            <AssetGallery selectable={true} multiSelect='manual' />
         </Page>
     );
 }

+ 156 - 0
packages/dashboard/src/app/routes/_authenticated/_assets/assets_.$id.tsx

@@ -0,0 +1,156 @@
+import { AssetPreview } from '@/components/shared/asset/asset-preview.js'
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { createFileRoute } from '@tanstack/react-router'
+import { assetDetailDocument, assetUpdateDocument } from './assets.graphql.js';
+import { Trans, useLingui } from '@/lib/trans.js';
+import { ErrorPage } from '@/components/shared/error-page.js';
+import { toast } from 'sonner';
+import { Page, PageTitle, PageActionBar, PageActionBarRight, PageBlock, PageLayout, CustomFieldsPageBlock } from '@/framework/layout-engine/page-layout.js'
+import { useDetailPage } from '@/framework/page/use-detail-page.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { Button } from '@/components/ui/button.js';
+import { VendureImage } from '@/components/shared/vendure-image.js';
+import { useState, useRef } from 'react';
+import { PreviewPreset } from '@/components/shared/asset/asset-preview.js';
+import { AssetPreviewSelector } from '@/components/shared/asset/asset-preview-selector.js';
+import { AssetProperties } from '@/components/shared/asset/asset-properties.js';
+import { AssetFocalPointEditor } from '@/components/shared/asset/asset-focal-point-editor.js';
+import { FocusIcon } from 'lucide-react';
+import { Point } from '@/components/shared/asset/focal-point-control.js';
+import { Label } from '@/components/ui/label.js';
+export const Route = createFileRoute('/_authenticated/_assets/assets_/$id')({
+    component: AssetDetailPage,
+    loader: detailPageRouteLoader({
+        queryDocument: assetDetailDocument,
+        breadcrumb(isNew, entity) {
+            return [
+                { path: '/assets', label: 'Assets' },
+                isNew ? <Trans>New asset</Trans> : entity?.name ?? '',
+            ];
+        },
+    }),
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+function AssetDetailPage() {
+    const params = Route.useParams();
+    const { i18n } = useLingui();
+
+    const imageRef = useRef<HTMLImageElement>(null);
+    const [size, setSize] = useState<PreviewPreset>('medium');
+    const [width, setWidth] = useState(0);
+    const [height, setHeight] = useState(0);
+    const [focalPoint, setFocalPoint] = useState<Point | undefined>(undefined);
+    const [settingFocalPoint, setSettingFocalPoint] = useState(false);
+    const { form, submitHandler, entity, isPending } = useDetailPage({
+        queryDocument: assetDetailDocument,
+        updateDocument: assetUpdateDocument,
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                focalPoint: entity.focalPoint,
+                name: entity.name,
+                tags: entity.tags?.map(tag => tag.value) ?? [],
+                customFields: entity.customFields,
+            };
+        },
+        params: { id: params.id },
+        onSuccess: async () => {
+            toast(i18n.t('Successfully updated asset'));
+            form.reset(form.getValues());
+        },
+        onError: err => {
+            toast(i18n.t('Failed to update asset'), {
+                description: err instanceof Error ? err.message : 'Unknown error',
+            });
+        },
+    });
+
+    const updateDimensions = () => {
+        if (!imageRef.current) return;
+        const img = imageRef.current;
+        const imgWidth = img.naturalWidth;
+        const imgHeight = img.naturalHeight;
+        setWidth(imgWidth);
+        setHeight(imgHeight);
+    };
+
+    if (!entity) {
+        return null;
+    }
+    return (
+        <Page pageId="asset-detail" form={form} submitHandler={submitHandler}>
+            <PageTitle>
+                <Trans>Edit asset</Trans>
+            </PageTitle>
+            <PageActionBar>
+                <PageActionBarRight>
+                    <PermissionGuard requires={['UpdateChannel']}>
+                        <Button
+                            type="submit"
+                            disabled={!form.formState.isDirty || isPending}
+                        >
+                            <Trans>Update</Trans>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
+            </PageActionBar>
+            <PageLayout>
+                <PageBlock column="main" blockId="asset-preview">
+                    <div className="relative flex items-center justify-center bg-muted/30 rounded-lg min-h-[300px] overflow-auto">
+                        <AssetFocalPointEditor
+                            width={width}
+                            height={height}
+                            settingFocalPoint={settingFocalPoint}
+                            focalPoint={form.getValues().focalPoint ?? { x: 0.5, y: 0.5 }}
+                            onFocalPointChange={(point) => {
+                                form.setValue('focalPoint.x', point.x, { shouldDirty: true });
+                                form.setValue('focalPoint.y', point.y, { shouldDirty: true });
+                                setSettingFocalPoint(false);
+                            }}
+                            onCancel={() => {
+                                setSettingFocalPoint(false);
+                            }}
+                        >
+                            <VendureImage
+                                ref={imageRef}
+                                asset={entity}
+                                preset={size || undefined}
+                                mode="resize"
+                                useFocalPoint={true}
+                                onLoad={updateDimensions}
+                                className="max-w-full max-h-full object-contain"
+                            />
+                        </AssetFocalPointEditor>
+                    </div>
+                </PageBlock>
+                <CustomFieldsPageBlock
+                    column="main"
+                    entityType={'Asset'}
+                    control={form.control}
+                />
+                <PageBlock column="side" blockId="asset-properties">
+                    <AssetProperties asset={entity} />
+                </PageBlock>
+                <PageBlock column="side" blockId="asset-size">
+                    <div className="flex flex-col gap-2">
+                        <AssetPreviewSelector size={size} setSize={setSize} width={width} height={height} />
+                        <div className="flex items-center gap-2">
+                            <Button type='button' variant="outline" size="icon" onClick={() => setSettingFocalPoint(true)}>
+                                <FocusIcon className="h-4 w-4" />
+                            </Button>
+                            <div className="text-sm text-muted-foreground">
+                                <Label><Trans>Focal Point</Trans></Label>
+                                <div className="text-sm text-muted-foreground">
+                                    {form.getValues().focalPoint?.x && form.getValues().focalPoint?.y
+                                        ? `${form.getValues().focalPoint?.x.toFixed(2)}, ${form.getValues().focalPoint?.y.toFixed(2)}`
+                                        : <Trans>Not set</Trans>}
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </PageBlock>
+            </PageLayout>
+        </Page>
+    )
+}

+ 104 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/customer-address-selector.tsx

@@ -0,0 +1,104 @@
+import { Button } from '@/components/ui/button.js';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover.js';
+import { api } from '@/graphql/api.js';
+import { graphql, ResultOf } from '@/graphql/graphql.js';
+import { useQuery } from '@tanstack/react-query';
+import { Trans } from '@/lib/trans.js';
+import { useLingui } from '@/lib/trans.js';
+import { addressFragment } from '../../_customers/customers.graphql.js';
+import { Card } from '@/components/ui/card.js';
+import { cn } from '@/lib/utils.js';
+import { useState } from 'react';
+import { Plus } from 'lucide-react';
+
+const getCustomerAddressesDocument = graphql(
+    `
+        query GetCustomerAddresses($customerId: ID!) {
+            customer(id: $customerId) {
+                id
+                addresses {
+                    ...Address
+                }
+            }
+        }
+    `,
+    [addressFragment],
+);
+
+type CustomerAddressesQuery = ResultOf<typeof getCustomerAddressesDocument>;
+
+interface CustomerAddressSelectorProps {
+    customerId: string | undefined;
+    onSelect: (address: ResultOf<typeof addressFragment>) => void;
+}
+
+export function CustomerAddressSelector({ customerId, onSelect }: CustomerAddressSelectorProps) {
+    const { i18n } = useLingui();
+    const [open, setOpen] = useState(false);
+
+    const { data, isLoading } = useQuery<CustomerAddressesQuery>({
+        queryKey: ['customerAddresses', customerId],
+        queryFn: () => api.query(getCustomerAddressesDocument, { customerId: customerId ?? '' }),
+        enabled: !!customerId,
+    });
+
+    const addresses: ResultOf<typeof addressFragment>[] = data?.customer?.addresses || [];
+
+    return (
+        <Popover open={open} onOpenChange={setOpen}>
+            <PopoverTrigger asChild>
+                <div className="flex items-center gap-2">
+                    <Button variant="outline" size="sm" type="button" className="" disabled={!customerId}>
+                        <Plus className="h-4 w-4" />
+                        <Trans>Select address</Trans>
+                    </Button>
+                </div>
+            </PopoverTrigger>
+            <PopoverContent className="w-[400px] p-0" align="start">
+                <div className="p-4">
+                    <h4 className="mb-4">
+                        <Trans>Select an address</Trans>
+                    </h4>
+                    <div className="space-y-2">
+                        {isLoading ? (
+                            <div className="text-sm text-muted-foreground">
+                                <Trans>Loading addresses...</Trans>
+                            </div>
+                        ) : addresses.length === 0 ? (
+                            <div className="text-sm text-muted-foreground">
+                                <Trans>No addresses found</Trans>
+                            </div>
+                        ) : (
+                            addresses.map(address => (
+                                <Card
+                                    key={address.id}
+                                    className={cn(
+                                        'p-4 cursor-pointer hover:bg-accent transition-colors',
+                                    )}
+                                    onClick={() => {
+                                        onSelect(address);
+                                        setOpen(false);
+                                    }}
+                                >
+                                    <div className="flex flex-col gap-1 text-sm">
+                                        <div className="font-semibold">{address.fullName}</div>
+                                        {address.company && <div>{address.company}</div>}
+                                        <div>{address.streetLine1}</div>
+                                        {address.streetLine2 && <div>{address.streetLine2}</div>}
+                                        <div>
+                                            {address.city}
+                                            {address.province && `, ${address.province}`}
+                                        </div>
+                                        <div>{address.postalCode}</div>
+                                        <div>{address.country.name}</div>
+                                        {address.phoneNumber && <div>{address.phoneNumber}</div>}
+                                    </div>
+                                </Card>
+                            ))
+                        )}
+                    </div>
+                </div>
+            </PopoverContent>
+        </Popover>
+    );
+}

+ 228 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/edit-order-table.tsx

@@ -0,0 +1,228 @@
+import { ProductVariantSelector } from '@/components/shared/product-variant-selector.js';
+import { VendureImage } from '@/components/shared/vendure-image.js';
+import { Button } from '@/components/ui/button.js';
+import { Input } from '@/components/ui/input.js';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table.js';
+import { ResultOf } from '@/graphql/graphql.js';
+import { Trans } from '@/lib/trans.js';
+import {
+    ColumnDef,
+    flexRender,
+    getCoreRowModel,
+    useReactTable,
+    VisibilityState,
+} from '@tanstack/react-table';
+import { Trash2 } from 'lucide-react';
+import { useState } from 'react';
+import { draftOrderEligibleShippingMethodsDocument, orderDetailDocument, orderLineFragment } from '../orders.graphql.js';
+import { MoneyGrossNet } from './money-gross-net.js';
+import { OrderTableTotals } from './order-table-totals.js';
+import { ShippingMethodSelector } from './shipping-method-selector.js';
+import { OrderLineCustomFieldsForm } from './order-line-custom-fields-form.js';
+import { UseFormReturn } from 'react-hook-form';
+
+type OrderFragment = NonNullable<ResultOf<typeof orderDetailDocument>['order']>;
+type OrderLineFragment = ResultOf<typeof orderLineFragment>;
+
+type ShippingMethodQuote = ResultOf<typeof draftOrderEligibleShippingMethodsDocument>['eligibleShippingMethodsForDraftOrder'][number];
+
+export interface OrderTableProps {
+    order: OrderFragment;
+    eligibleShippingMethods: ShippingMethodQuote[];
+    onAddItem: (event: { productVariantId: string; }) => void;
+    onAdjustLine: (event: { lineId: string; quantity: number; customFields: Record<string, any> }) => void;
+    onRemoveLine: (event: { lineId: string }) => void;
+    onSetShippingMethod: (event: { shippingMethodId: string }) => void;
+    onApplyCouponCode: (event: { couponCode: string }) => void;
+    onRemoveCouponCode: (event: { couponCode: string }) => void;
+    orderLineForm: UseFormReturn<any>;
+}
+
+export function EditOrderTable({ order, eligibleShippingMethods, onAddItem, onAdjustLine, onRemoveLine,
+    onSetShippingMethod, onApplyCouponCode, onRemoveCouponCode, orderLineForm }: OrderTableProps) {
+
+    const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
+    const [couponCode, setCouponCode] = useState('');
+
+    const currencyCode = order.currencyCode;
+
+    const columns: ColumnDef<OrderLineFragment>[] = [
+        {
+            header: 'Image',
+            accessorKey: 'featuredAsset',
+            cell: ({ row }) => {
+                const asset = row.original.featuredAsset;
+                return <VendureImage asset={asset} preset="tiny" />;
+            },
+        },
+        {
+            header: 'Product',
+            accessorKey: 'productVariant.name',
+        },
+        {
+            header: 'SKU',
+            accessorKey: 'productVariant.sku',
+        },
+        {
+            header: 'Unit price',
+            accessorKey: 'unitPriceWithTax',
+            cell: ({ row }) => {
+                const value = row.original.unitPriceWithTax
+                const netValue = row.original.unitPrice;
+                return <MoneyGrossNet priceWithTax={value} price={netValue} currencyCode={currencyCode} />
+            },
+        },
+        {
+            header: 'Quantity',
+            accessorKey: 'quantity',
+            cell: ({ row }) => {
+                return <div className="flex gap-2">
+                    <Input type="number" value={row.original.quantity} onChange={e => onAdjustLine({ lineId: row.original.id, quantity: e.target.valueAsNumber, customFields: row.original.customFields })} />
+                    <Button variant="outline" type="button" size="icon" onClick={() => onRemoveLine({ lineId: row.original.id })}>
+                        <Trash2 />
+                    </Button>
+                    {row.original.customFields &&
+                        <OrderLineCustomFieldsForm onUpdate={(customFields) => {
+                            
+                            onAdjustLine({
+                                lineId: row.original.id,
+                                quantity: row.original.quantity,
+                                customFields: customFields
+                            });
+                        }} form={orderLineForm} />}
+                </div>;
+            },
+        },
+        {
+            header: 'Total',
+            accessorKey: 'linePriceWithTax',
+            cell: ({ cell, row }) => {
+                const value = row.original.linePriceWithTax;
+                const netValue = row.original.linePrice;
+                return <MoneyGrossNet priceWithTax={value} price={netValue} currencyCode={currencyCode} />;
+            },
+        },
+    ];
+
+    const data = order.lines;
+
+    const table = useReactTable({
+        data,
+        columns,
+        getCoreRowModel: getCoreRowModel(),
+        rowCount: data.length,
+        onColumnVisibilityChange: setColumnVisibility,
+        state: {
+            columnVisibility,
+        },
+    });
+
+    return (
+        <div className="w-full">
+            <div className="">
+                <Table>
+                    <TableHeader>
+                        {table.getHeaderGroups().map(headerGroup => (
+                            <TableRow key={headerGroup.id}>
+                                {headerGroup.headers.map(header => {
+                                    return (
+                                        <TableHead key={header.id}>
+                                            {header.isPlaceholder
+                                                ? null
+                                                : flexRender(
+                                                    header.column.columnDef.header,
+                                                    header.getContext(),
+                                                )}
+                                        </TableHead>
+                                    );
+                                })}
+                            </TableRow>
+                        ))}
+                    </TableHeader>
+                    <TableBody>
+                        {table.getRowModel().rows?.length ? (
+                            table.getRowModel().rows.map(row => (
+                                <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
+                                    {row.getVisibleCells().map(cell => (
+                                        <TableCell key={cell.id}>
+                                            {flexRender(cell.column.columnDef.cell, cell.getContext())}
+                                        </TableCell>
+                                    ))}
+                                </TableRow>
+                            ))
+                        ) : null}
+                        <TableRow>
+                            <TableCell colSpan={columns.length} className="h-12">
+                                <div className="my-4 flex justify-center">
+                                    <div className="max-w-lg">
+                                        <ProductVariantSelector onProductVariantIdChange={variantId => {
+                                            onAddItem({ productVariantId: variantId });
+                                        }} />
+                                    </div>
+                                </div>
+                            </TableCell>
+                        </TableRow>
+                        <TableRow>
+                            <TableCell colSpan={columns.length} className="h-12">
+                                <ShippingMethodSelector
+                                    eligibleShippingMethods={eligibleShippingMethods}
+                                    selectedShippingMethodId={order.shippingLines?.[0]?.shippingMethod?.id}
+                                    currencyCode={currencyCode}
+                                    onSelect={(shippingMethodId) => onSetShippingMethod({ shippingMethodId })}
+                                />
+                            </TableCell>
+                        </TableRow>
+                        <TableRow>
+                            <TableCell colSpan={columns.length} className="h-12">
+                                <div className="flex flex-col gap-4">
+                                    <div className="flex gap-2">
+                                        <Input
+                                            type="text"
+                                            placeholder="Coupon code"
+                                            value={couponCode}
+                                            onChange={(e) => setCouponCode(e.target.value)}
+                                            onKeyDown={(e) => {
+                                                if (e.key === 'Enter') {
+                                                    onApplyCouponCode({ couponCode });
+                                                }
+                                            }}
+                                        />
+                                        <Button
+                                            type="button"
+                                            onClick={() => onApplyCouponCode({ couponCode })}
+                                            disabled={!couponCode}
+                                        >
+                                            <Trans>Apply</Trans>
+                                        </Button>
+                                    </div>
+                                    {order.couponCodes?.length > 0 && (
+                                        <div className="flex flex-wrap gap-2">
+                                            {order.couponCodes.map((code) => (
+                                                <div
+                                                    key={code}
+                                                    className="flex items-center gap-2 px-3 py-1 text-sm border rounded-md"
+                                                >
+                                                    <span>{code}</span>
+                                                    <Button
+                                                        type="button"
+                                                        variant="ghost"
+                                                        size="sm"
+                                                        className="h-6 w-6 p-0"
+                                                        onClick={() => onRemoveCouponCode({ couponCode: code })}
+                                                    >
+                                                        <Trash2 className="h-4 w-4" />
+                                                    </Button>
+                                                </div>
+                                            ))}
+                                        </div>
+                                    )}
+                                </div>
+                            </TableCell>
+                        </TableRow>
+                        <OrderTableTotals order={order} columnCount={columns.length} />
+                    </TableBody>
+                </Table>
+            </div>
+        </div>
+    );
+}

+ 18 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/money-gross-net.tsx

@@ -0,0 +1,18 @@
+import { Money } from "@/components/data-display/money.js";
+
+export interface MoneyGrossNetProps {
+    priceWithTax: number;
+    price: number;
+    currencyCode: string;
+}
+
+export function MoneyGrossNet({ priceWithTax, price, currencyCode }: MoneyGrossNetProps) {
+    return   <div className="flex flex-col gap-1">
+        <div>
+            <Money value={priceWithTax} currencyCode={currencyCode} />
+        </div>
+        <div className="text-xs text-muted-foreground">
+            <Money value={price} currencyCode={currencyCode} />
+        </div>
+    </div>;
+}

+ 2 - 1
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-address.tsx

@@ -32,7 +32,8 @@ export function OrderAddress({ address }: { address?: OrderAddress }) {
             <div className="text-sm">
                 {streetLine1 && <p>{streetLine1}</p>}
                 {streetLine2 && <p>{streetLine2}</p>}
-                <p>{[city, province, postalCode].filter(Boolean).join(', ')}</p>
+                <p>{[city, province].filter(Boolean).join(', ')}</p>
+                {postalCode && <p>{postalCode}</p>}
                 {country && (
                     <div className="flex items-center gap-1.5 mt-1">
                         <Globe className="h-3 w-3 text-muted-foreground" />

+ 38 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-line-custom-fields-form.tsx

@@ -0,0 +1,38 @@
+import { CustomFieldsForm } from '@/components/shared/custom-fields-form.js';
+import { Button } from '@/components/ui/button.js';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover.js';
+import { Settings2 } from 'lucide-react';
+import { UseFormReturn } from 'react-hook-form';
+import { Form } from '@/components/ui/form.js';
+
+interface OrderLineCustomFieldsFormProps {
+    onUpdate: (customFieldValues: Record<string, any>) => void;
+    form: UseFormReturn<any>;
+}
+
+export function OrderLineCustomFieldsForm({ onUpdate, form }: OrderLineCustomFieldsFormProps) {
+    const onSubmit = (values: any) => {
+        onUpdate(values.input?.customFields);
+    };
+    
+    return (
+        <Popover>
+            <PopoverTrigger asChild>
+                <Button variant="ghost" size="icon">
+                    <Settings2 className="h-4 w-4" />
+                </Button>
+            </PopoverTrigger>
+            <PopoverContent className="w-80">
+                <Form {...form}>
+                    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+                        <h4 className="font-medium leading-none">Custom Fields</h4>
+                        <CustomFieldsForm entityType="OrderLine" control={form.control} formPathPrefix='input' />
+                        <Button type="submit" className="w-full" disabled={!form.formState.isValid}>
+                            Update
+                        </Button>
+                    </form>
+                </Form>
+            </PopoverContent>
+        </Popover>
+    );
+}

+ 53 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-table-totals.tsx

@@ -0,0 +1,53 @@
+import { ResultOf } from "@/graphql/graphql.js";
+import { orderDetailDocument } from "../orders.graphql.js";
+import { TableRow, TableCell } from "@/components/ui/table.js";
+import { MoneyGrossNet } from "./money-gross-net.js";
+import { Trans } from "@/lib/trans.js";
+
+type OrderFragment = NonNullable<ResultOf<typeof orderDetailDocument>['order']>;
+
+export interface OrderTableTotalsProps {
+    order: OrderFragment;
+    columnCount: number;
+}
+
+export function OrderTableTotals({ order, columnCount }: OrderTableTotalsProps) {
+    const currencyCode = order.currencyCode;
+
+    return (
+        <>
+            {order.discounts?.length > 0 ? order.discounts.map(discount => <TableRow>
+                <TableCell colSpan={columnCount - 1} className="h-12">
+                    <Trans>Discount</Trans>: {discount.description}
+                </TableCell>
+                <TableCell colSpan={1} className="h-12">
+                    <MoneyGrossNet priceWithTax={discount.amountWithTax} price={discount.amount} currencyCode={currencyCode} />
+                </TableCell>
+            </TableRow>) : null}
+            <TableRow>
+                <TableCell colSpan={columnCount - 1} className="h-12">
+                    <Trans>Sub total</Trans>
+                </TableCell>
+                <TableCell colSpan={1} className="h-12">
+                    <MoneyGrossNet priceWithTax={order.subTotalWithTax} price={order.subTotal} currencyCode={currencyCode} />
+                </TableCell>
+            </TableRow>
+            <TableRow>
+                <TableCell colSpan={columnCount - 1} className="h-12">
+                    <Trans>Shipping</Trans>
+                </TableCell>
+                <TableCell colSpan={1} className="h-12">
+                    <MoneyGrossNet priceWithTax={order.shippingWithTax} price={order.shipping} currencyCode={currencyCode} />
+                </TableCell>
+            </TableRow>
+            <TableRow>
+                <TableCell colSpan={columnCount - 1} className="h-12 font-bold">
+                    <Trans>Total</Trans>
+                </TableCell>
+                <TableCell colSpan={1} className="h-12 font-bold">
+                    <MoneyGrossNet priceWithTax={order.totalWithTax} price={order.total} currencyCode={currencyCode} />
+                </TableCell>
+            </TableRow>
+        </>
+    )
+}

+ 8 - 49
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-table.tsx

@@ -1,3 +1,4 @@
+import { VendureImage } from '@/components/shared/vendure-image.js';
 import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table.js';
 import { ResultOf } from '@/graphql/graphql.js';
 import {
@@ -9,9 +10,8 @@ import {
 } from '@tanstack/react-table';
 import { useState } from 'react';
 import { orderDetailDocument, orderLineFragment } from '../orders.graphql.js';
-import { VendureImage } from '@/components/shared/vendure-image.js';
-import { Money } from '@/components/data-display/money.js';
-import { Trans } from '@/lib/trans.js';
+import { MoneyGrossNet } from './money-gross-net.js';
+import { OrderTableTotals } from './order-table-totals.js';
 
 type OrderFragment = NonNullable<ResultOf<typeof orderDetailDocument>['order']>;
 type OrderLineFragment = ResultOf<typeof orderLineFragment>;
@@ -46,18 +46,9 @@ export function OrderTable({ order }: OrderTableProps) {
             header: 'Unit price',
             accessorKey: 'unitPriceWithTax',
             cell: ({ cell, row }) => {
-                const value = cell.getValue();
+                const value = row.original.unitPriceWithTax;
                 const netValue = row.original.unitPrice;
-                return (
-                    <div className="flex flex-col gap-1">
-                        <div>
-                            <Money value={value} currencyCode={currencyCode} />
-                        </div>
-                        <div className="text-xs text-muted-foreground">
-                            <Money value={netValue} currencyCode={currencyCode} />
-                        </div>
-                    </div>
-                );
+                return <MoneyGrossNet priceWithTax={value} price={netValue} currencyCode={currencyCode} />;
             },
         },
         {
@@ -68,18 +59,9 @@ export function OrderTable({ order }: OrderTableProps) {
             header: 'Total',
             accessorKey: 'linePriceWithTax',
             cell: ({ cell, row }) => {
-                const value = cell.getValue();
+                const value = row.original.linePriceWithTax;
                 const netValue = row.original.linePrice;
-                return (
-                    <div className="flex flex-col gap-1">
-                        <div>
-                            <Money value={value} currencyCode={currencyCode} />
-                        </div>
-                        <div className="text-xs text-muted-foreground">
-                            <Money value={netValue} currencyCode={currencyCode} />
-                        </div>
-                    </div>
-                );
+                return <MoneyGrossNet priceWithTax={value} price={netValue} currencyCode={currencyCode} />;
             },
         },
     ];
@@ -137,30 +119,7 @@ export function OrderTable({ order }: OrderTableProps) {
                                 </TableCell>
                             </TableRow>
                         )}
-                        <TableRow>
-                            <TableCell colSpan={columns.length - 1} className="h-12">
-                                <Trans>Sub total</Trans>
-                            </TableCell>
-                            <TableCell colSpan={1} className="h-12">
-                                <Money value={order.subTotalWithTax} currencyCode={currencyCode} />
-                            </TableCell>
-                        </TableRow>
-                        <TableRow>
-                            <TableCell colSpan={columns.length - 1} className="h-12">
-                                <Trans>Shipping</Trans>
-                            </TableCell>
-                            <TableCell colSpan={1} className="h-12">
-                                <Money value={order.shippingWithTax} currencyCode={currencyCode} />
-                            </TableCell>
-                        </TableRow>
-                        <TableRow>
-                            <TableCell colSpan={columns.length - 1} className="h-12 font-bold">
-                                <Trans>Total</Trans>
-                            </TableCell>
-                            <TableCell colSpan={1} className="h-12 font-bold">
-                                <Money value={order.totalWithTax} currencyCode={currencyCode} />
-                            </TableCell>
-                        </TableRow>
+                        <OrderTableTotals order={order} columnCount={columns.length} />
                     </TableBody>
                 </Table>
             </div>

+ 65 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/shipping-method-selector.tsx

@@ -0,0 +1,65 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
+import { Money } from '@/components/data-display/money.js';
+import { Trans } from '@/lib/trans.js';
+import { draftOrderEligibleShippingMethodsDocument } from '../orders.graphql.js';
+import { ResultOf } from '@/graphql/graphql.js';
+
+type ShippingMethodQuote = ResultOf<typeof draftOrderEligibleShippingMethodsDocument>['eligibleShippingMethodsForDraftOrder'][number];
+
+interface ShippingMethodSelectorProps {
+    eligibleShippingMethods: ShippingMethodQuote[];
+    selectedShippingMethodId?: string;
+    currencyCode: string;
+    onSelect: (shippingMethodId: string) => void;
+}
+
+export function ShippingMethodSelector({ 
+    eligibleShippingMethods, 
+    selectedShippingMethodId,
+    currencyCode,
+    onSelect 
+}: ShippingMethodSelectorProps) {
+    return (
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+            {eligibleShippingMethods?.length ? eligibleShippingMethods.map(method => (
+                <Card 
+                    key={method.id} 
+                    className={`cursor-pointer hover:bg-muted/50 transition-colors border-2 border-transparent ${
+                        selectedShippingMethodId === method.id 
+                            ? 'border-primary' 
+                            : ''
+                    }`}
+                    onClick={() => onSelect(method.id)}
+                >
+                    <CardHeader className="pb-2">
+                        <CardTitle className="">
+                            <Trans>{method.name}</Trans>
+                        </CardTitle>
+                    </CardHeader>
+                    <CardContent>
+                        <div className="space-y-2">
+                            {method.description && (
+                                <p className="text-sm text-muted-foreground">
+                                    <Trans>{method.description}</Trans>
+                                </p>
+                            )}
+                            <div className="flex items-center justify-between">
+                                <span className="text-sm font-medium">
+                                    <Trans>Price</Trans>
+                                </span>
+                                <Money 
+                                    value={method.priceWithTax} 
+                                    currencyCode={currencyCode} 
+                                />
+                            </div>
+                        </div>
+                    </CardContent>
+                </Card>
+            )) : (
+                <div className="col-span-full text-center text-muted-foreground">
+                    <Trans>No shipping methods available</Trans>
+                </div>
+            )}
+        </div>
+    );
+} 

+ 187 - 1
packages/dashboard/src/app/routes/_authenticated/_orders/orders.graphql.ts

@@ -1,4 +1,4 @@
-import { assetFragment } from '@/graphql/fragments.js';
+import { assetFragment, errorResultFragment } from '@/graphql/fragments.js';
 import { graphql } from '@/graphql/graphql.js';
 import { gql } from 'awesome-graphql-client';
 
@@ -163,6 +163,7 @@ export const orderLineFragment = graphql(
             linePriceWithTax
             discountedLinePrice
             discountedLinePriceWithTax
+            customFields
         }
     `,
     [assetFragment],
@@ -214,6 +215,7 @@ export const orderDetailFragment = graphql(
             promotions {
                 id
                 couponCode
+                name
             }
             subTotal
             subTotalWithTax
@@ -323,3 +325,187 @@ export const orderHistoryDocument = graphql(`
         }
     }
 `);
+
+export const createDraftOrderDocument = graphql(`
+    mutation CreateDraftOrder {
+        createDraftOrder {
+            id
+        }
+    }
+`);
+
+export const deleteDraftOrderDocument = graphql(`
+    mutation DeleteDraftOrder($orderId: ID!) {
+        deleteDraftOrder(orderId: $orderId) {
+            result
+            message
+        }
+    }
+`);
+
+export const addItemToDraftOrderDocument = graphql(
+    `
+        mutation AddItemToDraftOrder($orderId: ID!, $input: AddItemToDraftOrderInput!) {
+            addItemToDraftOrder(orderId: $orderId, input: $input) {
+                __typename
+                ... on Order {
+                    id
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const adjustDraftOrderLineDocument = graphql(
+    `
+        mutation AdjustDraftOrderLine($orderId: ID!, $input: AdjustDraftOrderLineInput!) {
+            adjustDraftOrderLine(orderId: $orderId, input: $input) {
+                __typename
+                ... on Order {
+                    id
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const removeDraftOrderLineDocument = graphql(
+    `
+        mutation RemoveDraftOrderLine($orderId: ID!, $orderLineId: ID!) {
+            removeDraftOrderLine(orderId: $orderId, orderLineId: $orderLineId) {
+                __typename
+                ... on Order {
+                    id
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const setCustomerForDraftOrderDocument = graphql(
+    `
+        mutation SetCustomerForDraftOrder($orderId: ID!, $customerId: ID, $input: CreateCustomerInput) {
+            setCustomerForDraftOrder(orderId: $orderId, customerId: $customerId, input: $input) {
+                __typename
+                ... on Order {
+                    id
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const setShippingAddressForDraftOrderDocument = graphql(`
+    mutation SetDraftOrderShippingAddress($orderId: ID!, $input: CreateAddressInput!) {
+        setDraftOrderShippingAddress(orderId: $orderId, input: $input) {
+            id
+        }
+    }
+`);
+
+export const setBillingAddressForDraftOrderDocument = graphql(`
+    mutation SetDraftOrderBillingAddress($orderId: ID!, $input: CreateAddressInput!) {
+        setDraftOrderBillingAddress(orderId: $orderId, input: $input) {
+            id
+        }
+    }
+`);
+
+export const unsetShippingAddressForDraftOrderDocument = graphql(`
+    mutation UnsetDraftOrderShippingAddress($orderId: ID!) {
+        unsetDraftOrderShippingAddress(orderId: $orderId) {
+            id
+        }
+    }
+`);
+
+export const unsetBillingAddressForDraftOrderDocument = graphql(`
+    mutation UnsetDraftOrderBillingAddress($orderId: ID!) {
+        unsetDraftOrderBillingAddress(orderId: $orderId) {
+            id
+        }
+    }
+`);
+
+export const applyCouponCodeToDraftOrderDocument = graphql(
+    `
+        mutation ApplyCouponCodeToDraftOrder($orderId: ID!, $couponCode: String!) {
+            applyCouponCodeToDraftOrder(orderId: $orderId, couponCode: $couponCode) {
+                __typename
+                ... on Order {
+                    id
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const removeCouponCodeFromDraftOrderDocument = graphql(`
+    mutation RemoveCouponCodeFromDraftOrder($orderId: ID!, $couponCode: String!) {
+        removeCouponCodeFromDraftOrder(orderId: $orderId, couponCode: $couponCode) {
+            id
+        }
+    }
+`);
+
+export const draftOrderEligibleShippingMethodsDocument = graphql(`
+    query DraftOrderEligibleShippingMethods($orderId: ID!) {
+        eligibleShippingMethodsForDraftOrder(orderId: $orderId) {
+            id
+            name
+            code
+            description
+            price
+            priceWithTax
+            metadata
+        }
+    }
+`);
+
+export const setDraftOrderShippingMethodDocument = graphql(
+    `
+        mutation SetDraftOrderShippingMethod($orderId: ID!, $shippingMethodId: ID!) {
+            setDraftOrderShippingMethod(orderId: $orderId, shippingMethodId: $shippingMethodId) {
+                __typename
+                ... on Order {
+                    id
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const setDraftOrderCustomFieldsDocument = graphql(`
+    mutation SetDraftOrderCustomFields($orderId: ID!, $input: UpdateOrderInput!) {
+        setDraftOrderCustomFields(orderId: $orderId, input: $input) {
+            id
+        }
+    }
+`);
+
+export const transitionOrderToStateDocument = graphql(
+    `
+        mutation TransitionOrderToState($id: ID!, $state: String!) {
+            transitionOrderToState(id: $id, state: $state) {
+                __typename
+                ... on Order {
+                    id
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);

+ 39 - 18
packages/dashboard/src/app/routes/_authenticated/_orders/orders.tsx

@@ -4,9 +4,14 @@ import { Badge } from '@/components/ui/badge.js';
 import { Button } from '@/components/ui/button.js';
 import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@/lib/trans.js';
-import { createFileRoute, Link } from '@tanstack/react-router';
-import { orderListDocument } from './orders.graphql.js';
+import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
+import { createDraftOrderDocument, orderListDocument } from './orders.graphql.js';
 import { useServerConfig } from '@/hooks/use-server-config.js';
+import { PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
+import { PlusIcon } from 'lucide-react';
+import { useMutation } from '@tanstack/react-query';
+import { api } from '@/graphql/api.js';
+import { ResultOf } from '@/graphql/graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_orders/orders')({
     component: OrderListPage,
@@ -15,30 +20,39 @@ export const Route = createFileRoute('/_authenticated/_orders/orders')({
 
 function OrderListPage() {
     const serverConfig = useServerConfig();
+    const navigate = useNavigate();
+    const { mutate: createDraftOrder } = useMutation({
+        mutationFn: api.mutate(createDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof createDraftOrderDocument>) => {
+            navigate({ to: '/orders/draft/$id', params: { id: result.createDraftOrder.id } });
+        }
+    })
     return (
         <ListPage
             pageId="order-list"
             title="Orders"
             onSearchTermChange={searchTerm => {
                 return {
-                    code: {
-                        contains: searchTerm,
-                    },
-                    customerLastName: {
-                        contains: searchTerm,
-                    },
-                    transactionId: {
-                        contains: searchTerm,
-                    },
+                    _or: [
+                        {
+                            code: {
+                                contains: searchTerm,
+                            },
+                        },
+                        {
+                            customerLastName: {
+                                contains: searchTerm,
+                            },
+                        },
+                        {
+                            transactionId: {
+                                contains: searchTerm,
+                            },
+                        },
+                    ],
                 };
             }}
             defaultSort={[{ id: 'orderPlacedAt', desc: true }]}
-            transformVariables={variables => {
-                return {
-                    ...variables,
-                    filterOperator: 'OR',
-                };
-            }}
             listQuery={orderListDocument}
             route={Route}
             customizeColumns={{
@@ -115,6 +129,13 @@ function OrderListPage() {
                     }) ?? [],
                 },
             }}
-        />
+        >
+            <PageActionBarRight>
+                <Button onClick={() => createDraftOrder({})}>
+                    <PlusIcon className="mr-2 h-4 w-4" />
+                    <Trans>Draft order</Trans>
+                </Button>
+            </PageActionBarRight>
+        </ListPage>
     );
 }

+ 31 - 9
packages/dashboard/src/app/routes/_authenticated/_orders/orders_.$id.tsx

@@ -2,6 +2,7 @@ import { ErrorPage } from '@/components/shared/error-page.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Badge } from '@/components/ui/badge.js';
 import { Button } from '@/components/ui/button.js';
+import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import {
     CustomFieldsPageBlock,
     Page,
@@ -11,10 +12,10 @@ import {
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
-import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
-import { useDetailPage } from '@/framework/page/use-detail-page.js';
+import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { ResultOf } from '@/graphql/graphql.js';
 import { Trans, useLingui } from '@/lib/trans.js';
-import { Link, createFileRoute } from '@tanstack/react-router';
+import { Link, createFileRoute, redirect } from '@tanstack/react-router';
 import { User } from 'lucide-react';
 import { toast } from 'sonner';
 import { OrderAddress } from './components/order-address.js';
@@ -26,12 +27,33 @@ import { orderDetailDocument } from './orders.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_orders/orders_/$id')({
     component: OrderDetailPage,
-    loader: detailPageRouteLoader({
-        queryDocument: orderDetailDocument,
-        breadcrumb(_isNew, entity) {
-            return [{ path: '/orders', label: 'Orders' }, entity?.code];
-        },
-    }),
+    loader: async ({
+        context,
+        params,
+    }) => {
+        if (!params.id) {
+            throw new Error('ID param is required');
+        }
+
+        const result: ResultOf<typeof orderDetailDocument> = await context.queryClient.ensureQueryData(
+            getDetailQueryOptions(addCustomFields(orderDetailDocument), { id: params.id }),
+            { id: params.id },
+        );
+
+        if (!result.order) {
+            throw new Error(`Order with the ID ${params.id} was not found`);
+        }
+
+        if (result.order.state === 'Draft') {
+            throw redirect({
+                to: `/orders/draft/${params.id}`,
+            });
+        }
+
+        return {
+            breadcrumb: [{ path: '/orders', label: 'Orders' }, result.order.code],
+        };
+    },
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 

+ 418 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/orders_.draft.$id.tsx

@@ -0,0 +1,418 @@
+import { ConfirmationDialog } from '@/components/shared/confirmation-dialog.js';
+import { CustomerSelector } from '@/components/shared/customer-selector.js';
+import { ErrorPage } from '@/components/shared/error-page.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { Button } from '@/components/ui/button.js';
+import { Form } from '@/components/ui/form.js';
+import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
+import { useGeneratedForm } from '@/framework/form-engine/use-generated-form.js';
+import { CustomFieldsPageBlock, Page, PageActionBar, PageActionBarRight, PageBlock, PageLayout, PageTitle } from '@/framework/layout-engine/page-layout.js';
+import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { api } from '@/graphql/api.js';
+import { Trans, useLingui } from '@/lib/trans.js';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { createFileRoute, Link, redirect, useNavigate } from '@tanstack/react-router';
+import { ResultOf } from 'gql.tada';
+import { User } from 'lucide-react';
+import { toast } from 'sonner';
+import { CustomerAddressSelector } from './components/customer-address-selector.js';
+import { EditOrderTable } from './components/edit-order-table.js';
+import { OrderAddress } from './components/order-address.js';
+import { addItemToDraftOrderDocument, adjustDraftOrderLineDocument, applyCouponCodeToDraftOrderDocument, deleteDraftOrderDocument, draftOrderEligibleShippingMethodsDocument, orderDetailDocument, removeCouponCodeFromDraftOrderDocument, removeDraftOrderLineDocument, setBillingAddressForDraftOrderDocument, setCustomerForDraftOrderDocument, setDraftOrderCustomFieldsDocument, setDraftOrderShippingMethodDocument, setShippingAddressForDraftOrderDocument, transitionOrderToStateDocument, unsetBillingAddressForDraftOrderDocument, unsetShippingAddressForDraftOrderDocument } from './orders.graphql.js';
+import { CustomFieldsForm } from '@/components/shared/custom-fields-form.js';
+
+export const Route = createFileRoute('/_authenticated/_orders/orders_/draft/$id')({
+    component: DraftOrderPage,
+    loader: async ({
+        context,
+        params,
+    }) => {
+        if (!params.id) {
+            throw new Error('ID param is required');
+        }
+
+        const result: ResultOf<typeof orderDetailDocument> = await context.queryClient.ensureQueryData(
+            getDetailQueryOptions(addCustomFields(orderDetailDocument), { id: params.id }),
+            { id: params.id },
+        );
+
+        if (!result.order) {
+            throw new Error(`Order with the ID ${params.id} was not found`);
+        }
+
+        if (result.order.state !== 'Draft') {
+            throw redirect({
+                to: `/orders/${params.id}`,
+            });
+        }
+
+        return {
+            breadcrumb: [{ path: '/orders', label: 'Orders' }, result.order.code],
+        };
+    },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+function DraftOrderPage() {
+    const params = Route.useParams();
+    const { i18n } = useLingui();
+    const navigate = useNavigate();
+
+    const { entity, refreshEntity, form } = useDetailPage({
+        queryDocument: addCustomFields(orderDetailDocument),
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                customFields: entity.customFields,
+            };
+        },
+        params: { id: params.id },
+    });
+
+    const { form: orderLineForm } = useGeneratedForm({
+        document: addCustomFields(adjustDraftOrderLineDocument),
+        varName: undefined,
+        entity: entity?.lines[0],
+        setValues: entity => {
+            return {
+                orderId: entity.id,
+                input: {
+                    quantity: entity.quantity,
+                    orderLineId: entity.id,
+                    customFields: entity.customFields,
+                }
+            };
+        },
+    });
+
+    const { form: orderCustomFieldsForm } = useGeneratedForm({
+        document: setDraftOrderCustomFieldsDocument,
+        varName: undefined,
+        entity: entity,
+        setValues: entity => {
+            return {
+                orderId: entity.id,
+                input: {
+                    id: entity.id,
+                    customFields: entity.customFields,
+                }
+            };
+        },
+    });
+
+    const { mutate: setDraftOrderCustomFields } = useMutation({
+        mutationFn: api.mutate(setDraftOrderCustomFieldsDocument),
+        onSuccess: (result: ResultOf<typeof setDraftOrderCustomFieldsDocument>) => {
+            refreshEntity();
+        },
+    });
+
+    const { data: eligibleShippingMethods } = useQuery({
+        queryKey: ['eligibleShippingMethods', entity?.id],
+        queryFn: () => api.query(draftOrderEligibleShippingMethodsDocument, { orderId: entity?.id ?? '' }),
+        enabled: !!entity?.shippingAddress?.streetLine1,
+    });
+
+    const { mutate: addItemToDraftOrder } = useMutation({
+        mutationFn: api.mutate(addItemToDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof addItemToDraftOrderDocument>) => {
+            const order = result.addItemToDraftOrder;
+            switch (order.__typename) {
+                case 'Order':
+                    toast.success(i18n.t('Item added to order'));
+                    refreshEntity();
+                    break;
+                default:
+                    toast.error(order.message);
+                    break;
+            }
+        },
+    });
+
+    const { mutate: adjustDraftOrderLine } = useMutation({
+        mutationFn: api.mutate(adjustDraftOrderLineDocument),
+        onSuccess: (result: ResultOf<typeof adjustDraftOrderLineDocument>) => {
+            const order = result.adjustDraftOrderLine;
+            switch (order.__typename) {
+                case 'Order':
+                    toast.success(i18n.t('Order line updated'));
+                    refreshEntity();
+                    break;
+                default:
+                    toast.error(order.message);
+                    break;
+            }
+        },
+    });
+
+    const { mutate: removeDraftOrderLine } = useMutation({
+        mutationFn: api.mutate(removeDraftOrderLineDocument),
+        onSuccess: (result: ResultOf<typeof removeDraftOrderLineDocument>) => {
+            const order = result.removeDraftOrderLine;
+            switch (order.__typename) {
+                case 'Order':
+                    toast.success(i18n.t('Order line removed'));
+                    refreshEntity();
+                    break;
+                default:
+                    toast.error(order.message);
+                    break;
+            }
+        },
+    });
+
+    const { mutate: setCustomerForDraftOrder } = useMutation({
+        mutationFn: api.mutate(setCustomerForDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof setCustomerForDraftOrderDocument>) => {
+            const order = result.setCustomerForDraftOrder;
+            switch (order.__typename) {
+                case 'Order':
+                    toast.success(i18n.t('Customer set for order'));
+                    refreshEntity();
+                    break;
+                default:
+                    toast.error(order.message);
+                    break;
+            }
+        },
+    });
+
+    const { mutate: setShippingAddressForDraftOrder } = useMutation({
+        mutationFn: api.mutate(setShippingAddressForDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof setShippingAddressForDraftOrderDocument>) => {
+            toast.success(i18n.t('Shipping address set for order'));
+            refreshEntity();
+        },
+    });
+
+    const { mutate: setBillingAddressForDraftOrder } = useMutation({
+        mutationFn: api.mutate(setBillingAddressForDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof setBillingAddressForDraftOrderDocument>) => {
+            toast.success(i18n.t('Billing address set for order'));
+            refreshEntity();
+        },
+    });
+
+    const { mutate: unsetShippingAddressForDraftOrder } = useMutation({
+        mutationFn: api.mutate(unsetShippingAddressForDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof unsetShippingAddressForDraftOrderDocument>) => {
+            toast.success(i18n.t('Shipping address unset for order'));
+            refreshEntity();
+        },
+    });
+
+    const { mutate: unsetBillingAddressForDraftOrder } = useMutation({
+        mutationFn: api.mutate(unsetBillingAddressForDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof unsetBillingAddressForDraftOrderDocument>) => {
+            toast.success(i18n.t('Billing address unset for order'));
+            refreshEntity();
+        },
+    });
+
+    const { mutate: setShippingMethodForDraftOrder } = useMutation({
+        mutationFn: api.mutate(setDraftOrderShippingMethodDocument),
+        onSuccess: (result: ResultOf<typeof setDraftOrderShippingMethodDocument>) => {
+            const order = result.setDraftOrderShippingMethod;
+            switch (order.__typename) {
+                case 'Order':
+                    toast.success(i18n.t('Shipping method set for order'));
+                    refreshEntity();
+                    break;
+                default:
+                    toast.error(order.message);
+                    break;
+            }
+        },
+    });
+
+    const { mutate: setCouponCodeForDraftOrder } = useMutation({
+        mutationFn: api.mutate(applyCouponCodeToDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof applyCouponCodeToDraftOrderDocument>) => {
+            const order = result.applyCouponCodeToDraftOrder;
+            switch (order.__typename) {
+                case 'Order':
+                    toast.success(i18n.t('Coupon code set for order'));
+                    refreshEntity();
+                    break;
+                default:
+                    toast.error(order.message);
+                    break;
+            }
+        },
+    });
+
+    const { mutate: removeCouponCodeForDraftOrder } = useMutation({
+        mutationFn: api.mutate(removeCouponCodeFromDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof removeCouponCodeFromDraftOrderDocument>) => {
+            const order = result.removeCouponCodeFromDraftOrder;
+            toast.success(i18n.t('Coupon code removed from order'));
+            refreshEntity();
+        },
+    });
+
+    const { mutate: completeDraftOrder } = useMutation({
+        mutationFn: api.mutate(transitionOrderToStateDocument),
+        onSuccess: async (result: ResultOf<typeof transitionOrderToStateDocument>) => {
+            const order = result.transitionOrderToState;
+            switch (order?.__typename) {
+                case 'Order':
+                    toast.success(i18n.t('Draft order completed'));
+                    refreshEntity();
+                    setTimeout(() => {
+                        navigate({ to: `/orders/$id`, params: { id: order.id } });
+                    }, 500);
+                    break;
+                default:
+                    toast.error(order ? order.message : 'Unknown error');
+                    break;
+            }
+        },
+    });
+
+    const { mutate: deleteDraftOrder } = useMutation({
+        mutationFn: api.mutate(deleteDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof deleteDraftOrderDocument>) => {
+            if (result.deleteDraftOrder.result === 'DELETED') {
+                toast.success(i18n.t('Draft order deleted'));
+                navigate({ to: '/orders' });
+            } else {
+                toast.error(result.deleteDraftOrder.message);
+            }
+        },
+    });
+
+    if (!entity) {
+        return null;
+    }
+
+    const onSaveCustomFields = (values: any) => {
+        setDraftOrderCustomFields({ input: { id: entity.id, customFields: values.input?.customFields }, orderId: entity.id });
+    }
+
+    return (
+        <Page pageId="draft-order-detail" form={form}>
+            <PageTitle><Trans>Draft order</Trans>: {entity?.code ?? ''}</PageTitle>
+            <PageActionBar>
+                <PageActionBarRight>
+                    <PermissionGuard requires={['DeleteOrder']}>
+                        <ConfirmationDialog
+                            title={i18n.t('Delete draft order')}
+                            description={i18n.t('Are you sure you want to delete this draft order?')}
+                            onConfirm={() => {
+                                deleteDraftOrder({ orderId: entity.id });
+                            }}
+                        >
+                            <Button variant="destructive" type="button">
+                                <Trans>Delete draft</Trans>
+                            </Button>
+                        </ConfirmationDialog>
+
+                    </PermissionGuard>
+                    <PermissionGuard requires={['UpdateOrder']}>
+                        <Button type="button"
+                            disabled={!entity.customer || entity.lines.length === 0 || entity.shippingLines.length === 0 || entity.state !== 'Draft'}
+                            onClick={() => completeDraftOrder({ id: entity.id, state: 'ArrangingPayment' })}
+                        >
+                            <Trans>Complete draft</Trans>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
+            </PageActionBar>
+            <PageLayout>
+                <PageBlock column="main" blockId="order-table">
+                    <EditOrderTable order={entity}
+                        eligibleShippingMethods={eligibleShippingMethods?.eligibleShippingMethodsForDraftOrder ?? []}
+                        onSetShippingMethod={(e) => setShippingMethodForDraftOrder({ orderId: entity.id, shippingMethodId: e.shippingMethodId })}
+                        onAddItem={(e) => addItemToDraftOrder({ orderId: entity.id, input: { productVariantId: e.productVariantId, quantity: 1 } })}
+                        onAdjustLine={(e) => adjustDraftOrderLine({ orderId: entity.id, input: { orderLineId: e.lineId, quantity: e.quantity, customFields: e.customFields } as any })}
+                        onRemoveLine={(e) => removeDraftOrderLine({ orderId: entity.id, orderLineId: e.lineId })}
+                        onApplyCouponCode={(e) => setCouponCodeForDraftOrder({ orderId: entity.id, couponCode: e.couponCode })}
+                        onRemoveCouponCode={(e) => removeCouponCodeForDraftOrder({ orderId: entity.id, couponCode: e.couponCode })}
+                        orderLineForm={orderLineForm}
+                    />
+                </PageBlock>
+                <PageBlock column="main" blockId="order-custom-fields" title={<Trans>Custom fields</Trans>}>
+                    <Form {...orderCustomFieldsForm}>
+                        <CustomFieldsForm entityType="Order" control={orderCustomFieldsForm.control} formPathPrefix='input' />
+                        <div className="mt-4">
+                            <Button type="submit" className=""
+                                disabled={!orderCustomFieldsForm.formState.isValid || !orderCustomFieldsForm.formState.isDirty}
+                                onClick={(e) => {
+                                    e.preventDefault();
+                                    e.stopPropagation();
+                                    orderCustomFieldsForm.handleSubmit(onSaveCustomFields)();
+                                }}>
+                                <Trans>Set custom fields</Trans>
+                            </Button>
+                        </div>
+                    </Form>
+                </PageBlock>
+                <PageBlock column="side" blockId="customer" title={<Trans>Customer</Trans>}>
+                    {entity?.customer?.id ? <Button variant="ghost" asChild className="mb-4">
+                        <Link to={`/customers/${entity?.customer?.id}`}>
+                            <User className="w-4 h-4" />
+                            {entity?.customer?.firstName} {entity?.customer?.lastName}
+                        </Link>
+                    </Button> : null}
+                    <CustomerSelector onSelect={customer => {
+                        setCustomerForDraftOrder({ orderId: entity.id, customerId: customer.id });
+                    }} />
+                </PageBlock>
+                <PageBlock column="side" blockId="shipping-address" title={<Trans>Shipping address</Trans>}>
+                    <div className="flex flex-col">
+                        <OrderAddress address={entity.shippingAddress ?? undefined} />
+                        {entity.shippingAddress?.streetLine1
+                            ? <RemoveAddressButton onClick={() => unsetShippingAddressForDraftOrder({ orderId: entity.id })} />
+                            : <CustomerAddressSelector customerId={entity.customer?.id} onSelect={address => {
+                                setShippingAddressForDraftOrder({
+                                    orderId: entity.id, input: {
+                                        fullName: address.fullName,
+                                        company: address.company,
+                                        streetLine1: address.streetLine1,
+                                        streetLine2: address.streetLine2,
+                                        city: address.city,
+                                        province: address.province,
+                                        postalCode: address.postalCode,
+                                        countryCode: address.country.code,
+                                        phoneNumber: address.phoneNumber,
+                                    }
+                                });
+                            }} />
+                        }
+                    </div>
+                </PageBlock>
+                <PageBlock column="side" blockId="billing-address" title={<Trans>Billing address</Trans>}>
+                    <div className="flex flex-col">
+                        <OrderAddress address={entity.billingAddress ?? undefined} />
+                        {entity.billingAddress?.streetLine1
+                            ? <RemoveAddressButton onClick={() => unsetBillingAddressForDraftOrder({ orderId: entity.id })} />
+                            : <CustomerAddressSelector customerId={entity.customer?.id} onSelect={address => {
+                                setBillingAddressForDraftOrder({
+                                    orderId: entity.id, input: {
+                                        fullName: address.fullName,
+                                        company: address.company,
+                                        streetLine1: address.streetLine1,
+                                        streetLine2: address.streetLine2,
+                                        city: address.city,
+                                        province: address.province,
+                                        postalCode: address.postalCode,
+                                        countryCode: address.country.code,
+                                        phoneNumber: address.phoneNumber,
+                                    }
+                                });
+                            }} />
+                        }
+                    </div>
+                </PageBlock>
+            </PageLayout>
+        </Page>
+    );
+}
+
+function RemoveAddressButton(props: { onClick: () => void }) {
+    return (<div className="">
+        <Button variant="outline" className="mt-4" size="sm" onClick={props.onClick}>
+            <Trans>Remove</Trans>
+        </Button>
+    </div>)
+}

+ 1 - 1
packages/dashboard/src/app/routes/_authenticated/_products/products.tsx

@@ -38,7 +38,7 @@ function ProductListPage() {
                     <Button asChild>
                         <Link to="./new">
                             <PlusIcon className="mr-2 h-4 w-4" />
-                            New Product
+                            <Trans>New Product</Trans>
                         </Link>
                     </Button>
                 </PermissionGuard>

+ 61 - 0
packages/dashboard/src/lib/components/data-table/add-filter-menu.tsx

@@ -0,0 +1,61 @@
+import { Button } from '@/components/ui/button.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu.js';
+import {
+    Dialog,
+    DialogContent,
+    DialogDescription,
+    DialogHeader,
+    DialogTitle,
+    DialogTrigger,
+} from '@/components/ui/dialog.js';
+import { DataTableFilterDialog } from '@/components/data-table/data-table-filter-dialog.js';
+import { Column, ColumnDef } from '@tanstack/react-table';
+import { PlusCircle } from 'lucide-react';
+import { Trans } from '@/lib/trans.js';
+import React, { useState } from 'react';
+import { camelCaseToTitleCase } from '@/lib/utils.js';
+
+export interface AddFilterMenuProps {
+    columns: Column<any, unknown>[];
+}
+
+export function AddFilterMenu({ columns }: AddFilterMenuProps) {
+    const [selectedColumn, setSelectedColumn] = useState<ColumnDef<any> | null>(null);
+    const [isDialogOpen, setIsDialogOpen] = useState(false);
+
+    const filterableColumns = columns.filter(column => column.getCanFilter());
+
+    return (
+        <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
+            <DropdownMenu>
+                <DropdownMenuTrigger asChild>
+                    <Button variant="outline" size="sm" className="h-8 border-dashed">
+                        <PlusCircle className="mr-2 h-4 w-4" />
+                        <Trans>Add filter</Trans>
+                    </Button>
+                </DropdownMenuTrigger>
+                <DropdownMenuContent align="end" className="w-[200px]">
+                    {filterableColumns.map(column => (
+                        <DropdownMenuItem
+                            key={column.id}
+                            onSelect={() => {
+                                setSelectedColumn(column);
+                                setIsDialogOpen(true);
+                            }}
+                        >
+                            {camelCaseToTitleCase(column.id)}
+                        </DropdownMenuItem>
+                    ))}
+                </DropdownMenuContent>
+            </DropdownMenu>
+            {selectedColumn && (
+                <DataTableFilterDialog column={selectedColumn as any} />
+            )}
+        </Dialog>
+    );
+}

+ 0 - 13
packages/dashboard/src/lib/components/data-table/data-table-column-header.tsx

@@ -29,7 +29,6 @@ export interface DataTableColumnHeaderProps {
 export function DataTableColumnHeader({ headerContext, customConfig }: DataTableColumnHeaderProps) {
     const { column } = headerContext;
     const isSortable = column.getCanSort();
-    const isFilterable = column.getCanFilter();
 
     const customHeader = customConfig.header;
     let display = camelCaseToTitleCase(column.id);
@@ -40,7 +39,6 @@ export function DataTableColumnHeader({ headerContext, customConfig }: DataTable
     }
 
     const columSort = column.getIsSorted();
-    const columnFilter = column.getFilterValue();
     const nextSort = columSort === 'asc' ? true : columSort === 'desc' ? undefined : false;
 
     return (
@@ -57,17 +55,6 @@ export function DataTableColumnHeader({ headerContext, customConfig }: DataTable
                 </Button>
             )}
             <div>{display}</div>
-
-            {isFilterable && (
-                <Dialog>
-                    <DialogTrigger asChild>
-                        <Button size="icon-sm" variant="ghost">
-                            <Filter className={columnFilter ? '' : 'opacity-50'} />
-                        </Button>
-                    </DialogTrigger>
-                    <DataTableFilterDialog column={column} />
-                </Dialog>
-            )}
         </div>
     );
 }

+ 75 - 0
packages/dashboard/src/lib/components/data-table/data-table-filter-badge.tsx

@@ -0,0 +1,75 @@
+import { Filter } from 'lucide-react';
+
+import { CircleX } from 'lucide-react';
+import { Badge } from '../ui/badge.js';
+import { useLocalFormat } from '@/hooks/use-local-format.js';
+import { ColumnDataType } from './data-table-types.js';
+import { HumanReadableOperator } from './human-readable-operator.js';
+
+export function DataTableFilterBadge({
+    filter,
+    onRemove,
+    dataType,
+    currencyCode,
+}: {
+    filter: any;
+    onRemove: (filter: any) => void;
+    dataType: ColumnDataType;
+    currencyCode: string;
+}) {
+    const [operator, value] = Object.entries(filter.value as Record<string, unknown>)[0];
+    return (
+        <Badge key={filter.id} className="flex gap-1 items-center" variant="secondary">
+            <Filter size="12" className="opacity-50" />
+            <div>{filter.id}</div>
+            <div className="text-muted-foreground"><HumanReadableOperator operator={operator} mode="short" /></div>
+            <FilterValue value={value} dataType={dataType} currencyCode={currencyCode} />
+            <button className="cursor-pointer" onClick={() => onRemove(filter)}>
+                <CircleX size="14" />
+            </button>
+        </Badge>
+    );
+}
+
+function FilterValue({ value, dataType, currencyCode }: { value: unknown, dataType: ColumnDataType, currencyCode: string }) {
+    const { formatDate, formatCurrency } = useLocalFormat();
+    if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
+        return Object.entries(value as Record<string, unknown>).map(([key, value]) => (
+            <div key={key} className="flex gap-1 items-center">
+                <span className="text-muted-foreground">{key}: </span>
+                <FilterValue value={value} dataType={dataType} currencyCode={currencyCode} />
+            </div>
+        ));
+    }
+    if (Array.isArray(value)) {
+        return (
+            <div className="flex gap-1 items-center">
+                [
+                {value.map(v => (
+                    <FilterValue value={v} dataType={dataType} currencyCode={currencyCode} key={v} />
+                ))}
+                ]
+            </div>
+        );
+    }
+    if (typeof value === 'string' && isDateIsoString(value)) {
+        return <div>{formatDate(value, { dateStyle: 'short', timeStyle: 'short' })}</div>;
+    }
+    if (typeof value === 'boolean') {
+        return <div>{value ? 'true' : 'false'}</div>;
+    }
+    if (typeof value === 'number' && dataType === 'Money') {
+        return <div>{formatCurrency(value, currencyCode)}</div>;
+    }
+    if (typeof value === 'number') {
+        return <div>{value}</div>;
+    }
+    if (typeof value === 'string') {
+        return <div>{value}</div>;
+    }
+    return <div>{value as string}</div>;
+}
+
+function isDateIsoString(value: string) {
+    return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value);
+}

+ 27 - 28
packages/dashboard/src/lib/components/data-table/data-table-filter-dialog.tsx

@@ -7,23 +7,25 @@ import {
     DialogHeader,
     DialogTitle,
 } from '@/components/ui/dialog.js';
-import { Input } from '@/components/ui/input.js';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
 import { Trans } from '@/lib/trans.js';
 import { Column } from '@tanstack/react-table';
-import React, { useState } from 'react';
+import { useState } from 'react';
+import { DataTableBooleanFilter } from './filters/data-table-boolean-filter.js';
+import { DataTableDateTimeFilter } from './filters/data-table-datetime-filter.js';
+import { DataTableIdFilter } from './filters/data-table-id-filter.js';
+import { DataTableNumberFilter } from './filters/data-table-number-filter.js';
+import { DataTableStringFilter } from './filters/data-table-string-filter.js';
+import { ColumnDataType } from './data-table-types.js';
 
 export interface DataTableFilterDialogProps {
     column: Column<any>;
 }
 
-const STRING_OPERATORS = ['eq', 'notEq', 'contains', 'notContains', 'in', 'notIn', 'regex', 'isNull'];
-
 export function DataTableFilterDialog({ column }: DataTableFilterDialogProps) {
     const columnFilter = column.getFilterValue() as Record<string, string> | undefined;
-    const [initialOperator, initialValue] = columnFilter ? Object.entries(columnFilter as any)[0] : [];
-    const [operator, setOperator] = useState<string>(initialOperator ?? 'contains');
-    const [value, setValue] = useState((initialValue as string) ?? '');
+    const [filter, setFilter] = useState(columnFilter);
+
+    const columnDataType = (column.columnDef.meta as any)?.fieldInfo?.type as ColumnDataType;
     const columnId = column.id;
     return (
         <DialogContent>
@@ -33,25 +35,19 @@ export function DataTableFilterDialog({ column }: DataTableFilterDialogProps) {
                 </DialogTitle>
                 <DialogDescription></DialogDescription>
             </DialogHeader>
-            <div className="flex flex-col md:flex-row gap-2">
-                <Select value={operator} onValueChange={value => setOperator(value)}>
-                    <SelectTrigger>
-                        <SelectValue placeholder="Select operator" />
-                    </SelectTrigger>
-                    <SelectContent>
-                        {STRING_OPERATORS.map(op => (
-                            <SelectItem key={op} value={op}>
-                                <Trans context="filter-operator">{op}</Trans>
-                            </SelectItem>
-                        ))}
-                    </SelectContent>
-                </Select>
-                <Input
-                    placeholder="Enter filter value..."
-                    value={value}
-                    onChange={e => setValue(e.target.value)}
-                />
-            </div>
+            {columnDataType === 'String' ? (
+                <DataTableStringFilter value={filter} onChange={e => setFilter(e)} />
+            ) : columnDataType === 'Int' || columnDataType === 'Float' ? (
+                <DataTableNumberFilter value={filter} onChange={e => setFilter(e)} mode='number' />
+            ) : columnDataType === 'DateTime' ? (
+                <DataTableDateTimeFilter value={filter} onChange={e => setFilter(e)} />
+            ) : columnDataType === 'Boolean' ? (
+                <DataTableBooleanFilter value={filter} onChange={e => setFilter(e)} />
+            ) : columnDataType === 'ID' ? (
+                <DataTableIdFilter value={filter} onChange={e => setFilter(e)} />
+            ) : columnDataType === 'Money' ? (
+                <DataTableNumberFilter value={filter} onChange={e => setFilter(e)} mode='money' />
+            ) : null}
             <DialogFooter className="sm:justify-end">
                 {columnFilter && (
                     <Button type="button" variant="secondary" onClick={e => column.setFilterValue(undefined)}>
@@ -62,7 +58,10 @@ export function DataTableFilterDialog({ column }: DataTableFilterDialogProps) {
                     <Button
                         type="button"
                         variant="secondary"
-                        onClick={e => column.setFilterValue({ [operator]: value })}
+                            onClick={e => {
+                            column.setFilterValue(filter);
+                            setFilter(undefined);
+                        }}
                     >
                         <Trans>Apply filter</Trans>
                     </Button>

+ 1 - 0
packages/dashboard/src/lib/components/data-table/data-table-types.ts

@@ -0,0 +1 @@
+export type ColumnDataType = 'String' | 'Int' | 'Float' | 'DateTime' | 'Boolean' | 'ID' | 'Money';

+ 72 - 23
packages/dashboard/src/lib/components/data-table/data-table-view-options.tsx

@@ -1,51 +1,100 @@
 'use client';
 
-import { Badge } from '@/components/ui/badge.js';
+import { DndContext, closestCenter } from '@dnd-kit/core';
+import {
+    restrictToVerticalAxis,
+} from '@dnd-kit/modifiers';
+import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
 import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';
 import { Table } from '@tanstack/react-table';
-import { CircleX, Cross, Filter, Settings2 } from 'lucide-react';
+import { GripVertical, Settings2 } from 'lucide-react';
 
 import { Button } from '@/components/ui/button.js';
 import {
     DropdownMenu,
     DropdownMenuCheckboxItem,
-    DropdownMenuContent,
-    DropdownMenuLabel,
-    DropdownMenuSeparator,
+    DropdownMenuContent
 } from '@/components/ui/dropdown-menu.js';
+import { usePage } from '@/hooks/use-page.js';
+import { useUserSettings } from '@/hooks/use-user-settings.js';
+import { Trans } from '@/lib/trans.js';
 
 interface DataTableViewOptionsProps<TData> {
     table: Table<TData>;
 }
 
+function SortableItem({ id, children }: { id: string; children: React.ReactNode }) {
+    const {
+        attributes,
+        listeners,
+        setNodeRef,
+        transform,
+        transition,
+    } = useSortable({ id });
+
+    const style = {
+        transform: CSS.Transform.toString(transform),
+        transition,
+    };
+
+    return (
+        <div ref={setNodeRef} style={style} className="flex items-center gap-.5">
+            <div {...attributes} {...listeners} className="cursor-grab">
+                <GripVertical className="h-4 w-4 text-muted-foreground" />
+            </div>
+            {children}
+        </div>
+    );
+}
+
 export function DataTableViewOptions<TData>({ table }: DataTableViewOptionsProps<TData>) {
+    const { setTableSettings } = useUserSettings();
+    const page = usePage();
+    const columns = table
+        .getAllColumns()
+        .filter(column => typeof column.accessorFn !== 'undefined' && column.getCanHide());
+
+    const handleDragEnd = (event: any) => {
+        const { active, over } = event;
+        if (active.id !== over.id) {
+            const activeIndex = columns.findIndex(col => col.id === active.id);
+            const overIndex = columns.findIndex(col => col.id === over.id);
+            // update the column order in the `columns` array
+            const newColumns = [...columns];
+            newColumns.splice(overIndex, 0, newColumns.splice(activeIndex, 1)[0]);
+            if (page?.pageId) {
+                setTableSettings(page.pageId, 'columnOrder', newColumns.map(col => col.id));
+            }
+        }
+    };
+
     return (
         <div className="flex items-center gap-2">
             <DropdownMenu>
                 <DropdownMenuTrigger asChild>
                     <Button variant="outline" size="sm" className="ml-auto hidden h-8 lg:flex">
                         <Settings2 />
-                        View
+                        <Trans>Columns</Trans>
                     </Button>
                 </DropdownMenuTrigger>
                 <DropdownMenuContent align="end" className="w-[150px]">
-                    <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
-                    <DropdownMenuSeparator />
-                    {table
-                        .getAllColumns()
-                        .filter(column => typeof column.accessorFn !== 'undefined' && column.getCanHide())
-                        .map(column => {
-                            return (
-                                <DropdownMenuCheckboxItem
-                                    key={column.id}
-                                    className="capitalize"
-                                    checked={column.getIsVisible()}
-                                    onCheckedChange={value => column.toggleVisibility(!!value)}
-                                >
-                                    {column.id}
-                                </DropdownMenuCheckboxItem>
-                            );
-                        })}
+                    <DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd} modifiers={[restrictToVerticalAxis]}>
+                        <SortableContext items={columns.map(col => col.id)} strategy={verticalListSortingStrategy}>
+                            {columns.map(column => (
+                                <SortableItem key={column.id} id={column.id}>
+                                    <DropdownMenuCheckboxItem
+                                        className="capitalize"
+                                        checked={column.getIsVisible()}
+                                        onCheckedChange={value => column.toggleVisibility(!!value)}
+                                        onSelect={(e) => e.preventDefault()}
+                                    >
+                                        {column.id}
+                                    </DropdownMenuCheckboxItem>
+                                </SortableItem>
+                            ))}
+                        </SortableContext>
+                    </DndContext>
                 </DropdownMenuContent>
             </DropdownMenu>
         </div>

+ 23 - 24
packages/dashboard/src/lib/components/data-table/data-table.tsx

@@ -2,7 +2,6 @@
 
 import { DataTablePagination } from '@/components/data-table/data-table-pagination.js';
 import { DataTableViewOptions } from '@/components/data-table/data-table-view-options.js';
-import { Badge } from '@/components/ui/badge.js';
 import { Input } from '@/components/ui/input.js';
 import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table.js';
 import {
@@ -19,9 +18,11 @@ import {
     VisibilityState,
 } from '@tanstack/react-table';
 import { TableOptions } from '@tanstack/table-core';
-import { CircleX, Filter } from 'lucide-react';
 import React, { Suspense, useEffect } from 'react';
+import { AddFilterMenu } from './add-filter-menu.js';
 import { DataTableFacetedFilter, DataTableFacetedFilterOption } from './data-table-faceted-filter.js';
+import { DataTableFilterBadge } from './data-table-filter-badge.js';
+import { useChannel } from '@/hooks/use-channel.js';
 
 export interface FacetedFilter {
     title: string;
@@ -41,6 +42,7 @@ interface DataTableProps<TData> {
     onPageChange?: (table: TableType<TData>, page: number, itemsPerPage: number) => void;
     onSortChange?: (table: TableType<TData>, sorting: SortingState) => void;
     onFilterChange?: (table: TableType<TData>, columnFilters: ColumnFilter[]) => void;
+    onColumnVisibilityChange?: (table: TableType<TData>, columnVisibility: VisibilityState) => void;
     onSearchTermChange?: (searchTerm: string) => void;
     defaultColumnVisibility?: VisibilityState;
     facetedFilters?: { [key: string]: FacetedFilter | undefined };
@@ -64,6 +66,7 @@ export function DataTable<TData>({
     onSortChange,
     onFilterChange,
     onSearchTermChange,
+    onColumnVisibilityChange,
     defaultColumnVisibility,
     facetedFilters,
     disableViewOptions,
@@ -71,6 +74,7 @@ export function DataTable<TData>({
 }: DataTableProps<TData>) {
     const [sorting, setSorting] = React.useState<SortingState>(sortingInitialState || []);
     const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(filtersInitialState || []);
+    const { activeChannel } = useChannel();
     const [pagination, setPagination] = React.useState<PaginationState>({
         pageIndex: (page ?? 1) - 1,
         pageSize: itemsPerPage ?? 10,
@@ -117,6 +121,11 @@ export function DataTable<TData>({
     useEffect(() => {
         onFilterChange?.(table, columnFilters);
     }, [columnFilters]);
+
+    useEffect(() => {
+        onColumnVisibilityChange?.(table, columnVisibility);
+    }, [columnVisibility]);
+
     return (
         <>
             <div className="flex justify-between items-start">
@@ -142,30 +151,20 @@ export function DataTable<TData>({
                                 />
                             ))}
                         </Suspense>
+                        <AddFilterMenu columns={table.getAllColumns()} />
                     </div>
                     <div className="flex gap-1">
                         {columnFilters
                             .filter(f => !facetedFilters?.[f.id])
                             .map(f => {
-                                const [operator, value] = Object.entries(
-                                    f.value as Record<string, string>,
-                                )[0];
-                                return (
-                                    <Badge key={f.id} className="flex gap-1 items-center" variant="secondary">
-                                        <Filter size="12" className="opacity-50" />
-                                        <div>{f.id}</div>
-                                        <div>{operator}</div>
-                                        <div>{value}</div>
-                                        <button
-                                            className="cursor-pointer"
-                                            onClick={() =>
-                                                setColumnFilters(old => old.filter(x => x.id !== f.id))
-                                            }
-                                        >
-                                            <CircleX size="14" />
-                                        </button>
-                                    </Badge>
-                                );
+                                const column = table.getColumn(f.id);
+                                const currency = activeChannel?.defaultCurrencyCode ?? 'USD';
+                                return <DataTableFilterBadge
+                                            key={f.id}
+                                            filter={f}
+                                            currencyCode={currency}
+                                            dataType={(column?.columnDef.meta as any)?.fieldInfo?.type ?? 'String'}
+                                            onRemove={() => setColumnFilters(old => old.filter(x => x.id !== f.id))} />;
                             })}
                     </div>
                 </div>
@@ -182,9 +181,9 @@ export function DataTable<TData>({
                                             {header.isPlaceholder
                                                 ? null
                                                 : flexRender(
-                                                      header.column.columnDef.header,
-                                                      header.getContext(),
-                                                  )}
+                                                    header.column.columnDef.header,
+                                                    header.getContext(),
+                                                )}
                                         </TableHead>
                                     );
                                 })}

+ 57 - 0
packages/dashboard/src/lib/components/data-table/filters/data-table-boolean-filter.tsx

@@ -0,0 +1,57 @@
+import { Trans } from "@/lib/trans.js";
+
+import { Select, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select.js";
+
+import { SelectContent } from "@/components/ui/select.js";
+import { useEffect, useState } from "react";
+import { HumanReadableOperator } from "../human-readable-operator.js";
+
+export interface DataTableBooleanFilterProps {
+    value: Record<string, any> | undefined;
+    onChange: (filter: Record<string, any>) => void;
+}
+
+export const BOOLEAN_OPERATORS = ['eq', 'isNull'] as const;
+
+export function DataTableBooleanFilter({ value: incomingValue, onChange }: DataTableBooleanFilterProps) {
+    const initialOperator = incomingValue ? Object.keys(incomingValue)[0] ?? 'eq' : 'eq';
+    const initialValue = incomingValue ? Object.values(incomingValue)[0] : true;
+    const [operator, setOperator] = useState<string>(initialOperator ?? 'eq');
+    const [value, setValue] = useState<boolean>(initialValue as boolean ?? true);
+
+    useEffect(() => {
+        onChange({ [operator]: value });
+    }, [operator, value]);
+
+    return (
+        <div className="flex flex-col md:flex-row gap-2">
+            <Select value={operator} onValueChange={value => setOperator(value)}>
+                <SelectTrigger>
+                    <SelectValue placeholder="Select operator" />
+                </SelectTrigger>
+                <SelectContent>
+                    {BOOLEAN_OPERATORS.map(op => (
+                        <SelectItem key={op} value={op}>
+                            <HumanReadableOperator operator={op} />
+                        </SelectItem>
+                    ))}
+                </SelectContent>
+            </Select>
+            {operator !== 'isNull' && (
+                <Select value={value.toString()} onValueChange={v => setValue(v === 'true')}>
+                    <SelectTrigger>
+                        <SelectValue placeholder="Select value" />
+                    </SelectTrigger>
+                    <SelectContent>
+                        <SelectItem value="true">
+                            <Trans>True</Trans>
+                        </SelectItem>
+                        <SelectItem value="false">
+                            <Trans>False</Trans>
+                        </SelectItem>
+                    </SelectContent>
+                </Select>
+            )}
+        </div>
+    );
+}

+ 93 - 0
packages/dashboard/src/lib/components/data-table/filters/data-table-datetime-filter.tsx

@@ -0,0 +1,93 @@
+import { Trans } from "@/lib/trans.js";
+import { Select, SelectValue, SelectTrigger, SelectItem } from "@/components/ui/select.js";
+import { SelectContent } from "@/components/ui/select.js";
+import { useEffect, useState } from "react";
+import { DateTimeInput } from "@/components/data-input/datetime-input.js";
+import { HumanReadableOperator } from "../human-readable-operator.js";
+
+export interface DataTableDateTimeFilterProps {
+    value: Record<string, any> | undefined;
+    onChange: (filter: Record<string, any>) => void;
+}
+
+export const DATETIME_OPERATORS = ['eq', 'before', 'after', 'between', 'isNull'] as const;
+
+export function DataTableDateTimeFilter({ value: incomingValue, onChange }: DataTableDateTimeFilterProps) {
+    const initialOperator = incomingValue ? Object.keys(incomingValue)[0] : 'eq';
+    const initialValue = incomingValue ? Object.values(incomingValue)[0] : '';
+    const [operator, setOperator] = useState<string>(initialOperator ?? 'eq');
+    const [value, setValue] = useState<Date | undefined>(initialValue ? new Date(initialValue) : undefined);
+    const [startDate, setStartDate] = useState<Date | undefined>(undefined);
+    const [endDate, setEndDate] = useState<Date | undefined>(undefined);
+    const [error, setError] = useState<string>('');
+
+    useEffect(() => {
+        if (operator === 'isNull') {
+            onChange({ [operator]: true });
+            return;
+        }
+
+        if (operator === 'between') {
+            if (!startDate && !endDate) {
+                onChange({});
+                return;
+            }
+            if (!startDate || !endDate) {
+                setError('Please enter both start and end dates');
+                return;
+            }
+            if (startDate > endDate) {
+                setError('Start date must be before end date');
+                return;
+            }
+            setError('');
+            onChange({ [operator]: { start: startDate.toISOString(), end: endDate.toISOString() } });
+        } else {
+            if (!value) {
+                onChange({});
+                return;
+            }
+            setError('');
+            onChange({ [operator]: value.toISOString() });
+        }
+    }, [operator, value, startDate, endDate]);
+
+    return (
+        <div className="flex flex-col gap-2">
+            <div className="flex flex-col md:flex-row gap-2">
+                <Select value={operator} onValueChange={value => setOperator(value)}>
+                    <SelectTrigger>
+                        <SelectValue placeholder="Select operator" />
+                    </SelectTrigger>
+                    <SelectContent>
+                        {DATETIME_OPERATORS.map(op => (
+                            <SelectItem key={op} value={op}>
+                                <HumanReadableOperator operator={op} />
+                            </SelectItem>
+                        ))}
+                    </SelectContent>
+                </Select>
+                {operator !== 'isNull' && (
+                    operator === 'between' ? (
+                        <div className="space-y-2">
+                            <DateTimeInput
+                                value={startDate}
+                                onChange={setStartDate}
+                            />
+                            <DateTimeInput
+                                value={endDate}
+                                onChange={setEndDate}
+                            />
+                        </div>
+                    ) : (
+                        <DateTimeInput
+                            value={value}
+                            onChange={setValue}
+                        />
+                    )
+                )}
+            </div>
+            {error && <p className="text-sm text-red-500">{error}</p>}
+        </div>
+    );
+}

+ 58 - 0
packages/dashboard/src/lib/components/data-table/filters/data-table-id-filter.tsx

@@ -0,0 +1,58 @@
+import { Trans } from "@/lib/trans.js";
+
+import { Select, SelectValue, SelectTrigger, SelectItem } from "@/components/ui/select.js";
+
+import { SelectContent } from "@/components/ui/select.js";
+import { Input } from "@/components/ui/input.js";
+import { useEffect, useState } from "react";
+import { HumanReadableOperator } from "../human-readable-operator.js";
+
+export interface DataTableIdFilterProps {
+    value: Record<string, any> | undefined;
+    onChange: (filter: Record<string, any>) => void;
+}
+
+export const ID_OPERATORS = ['eq', 'notEq', 'in', 'notIn', 'isNull'] as const;
+
+export function DataTableIdFilter({ value: incomingValue, onChange }: DataTableIdFilterProps) {
+    const initialOperator = incomingValue ? Object.keys(incomingValue)[0] : 'eq';
+    const initialValue = incomingValue ? Object.values(incomingValue)[0] : '';
+    const [operator, setOperator] = useState<string>(initialOperator ?? 'eq');
+    const [value, setValue] = useState<string>(initialValue ?? '');
+
+    useEffect(() => {
+        if (operator === 'isNull') {
+            onChange({ [operator]: true });
+        } else if (operator === 'in' || operator === 'notIn') {
+            // Split by comma and trim whitespace
+            const values = value.split(',').map(v => v.trim()).filter(v => v);
+            onChange({ [operator]: values });
+        } else {
+            onChange({ [operator]: value });
+        }
+    }, [operator, value]);
+
+    return (
+        <div className="flex flex-col md:flex-row gap-2">
+            <Select value={operator} onValueChange={value => setOperator(value)}>
+                <SelectTrigger>
+                    <SelectValue placeholder="Select operator" />
+                </SelectTrigger>
+                <SelectContent>
+                    {ID_OPERATORS.map(op => (
+                        <SelectItem key={op} value={op}>
+                            <HumanReadableOperator operator={op} />
+                        </SelectItem>
+                    ))}
+                </SelectContent>
+            </Select>
+            {operator !== 'isNull' && (
+                <Input
+                    placeholder={operator === 'in' || operator === 'notIn' ? "Enter comma-separated IDs..." : "Enter ID..."}
+                    value={value}
+                    onChange={e => setValue(e.target.value)}
+                />
+            )}
+        </div>
+    );
+}

+ 119 - 0
packages/dashboard/src/lib/components/data-table/filters/data-table-number-filter.tsx

@@ -0,0 +1,119 @@
+import { Trans } from "@/lib/trans.js";
+import { Select, SelectValue, SelectTrigger, SelectItem } from "@/components/ui/select.js";
+import { SelectContent } from "@/components/ui/select.js";
+import { Input } from "@/components/ui/input.js";
+import { MoneyInput } from "@/components/data-input/money-input.js";
+import { useEffect, useState } from "react";
+import { useChannel } from "@/hooks/use-channel.js";
+import { HumanReadableOperator } from "../human-readable-operator.js";
+
+export interface DataTableNumberFilterProps {
+    mode: 'number' | 'money';
+    value: Record<string, any> | undefined;
+    onChange: (filter: Record<string, any>) => void;
+}
+
+export const NUMBER_OPERATORS = ['eq', 'gt', 'gte', 'lt', 'lte', 'isNull', 'between'] as const;
+
+export function DataTableNumberFilter({ mode, value: incomingValue, onChange }: DataTableNumberFilterProps) {
+    const { activeChannel } = useChannel();
+    const initialOperator = incomingValue ? Object.keys(incomingValue)[0] : 'eq';
+    const initialValue = incomingValue ? Object.values(incomingValue)[0] : 0;
+    const [operator, setOperator] = useState<string>(initialOperator ?? 'eq');
+    const [value, setValue] = useState<string>(initialValue?.toString() ?? '');
+    const [minValue, setMinValue] = useState<string>('');
+    const [maxValue, setMaxValue] = useState<string>('');
+    const [error, setError] = useState<string>('');
+
+    useEffect(() => {
+        if (operator === 'isNull') {
+            onChange({ [operator]: true });
+            return;
+        }
+
+        if (operator === 'between') {
+            if (!minValue && !maxValue) {
+                onChange({});
+                return;
+            }
+            if (!minValue || !maxValue) {
+                setError('Please enter both min and max values');
+                return;
+            }
+            const minNum = parseFloat(minValue);
+            const maxNum = parseFloat(maxValue);
+            if (isNaN(minNum) || isNaN(maxNum)) {
+                setError('Please enter valid numbers');
+                return;
+            }
+            if (minNum > maxNum) {
+                setError('Min value must be less than max value');
+                return;
+            }
+            setError('');
+            onChange({ [operator]: { start: minNum, end: maxNum } });
+        } else {
+            if (!value) {
+                onChange({});
+                return;
+            }
+            const numValue = parseFloat(value);
+            if (isNaN(numValue)) {
+                setError('Please enter a valid number');
+                return;
+            }
+            setError('');
+            onChange({ [operator]: numValue });
+        }
+    }, [operator, value, minValue, maxValue]);
+
+    const renderInput = (value: string, onChange: (value: string) => void, placeholder: string) => {
+        if (mode === 'money') {
+            return (
+                <MoneyInput
+                    value={parseFloat(value) || 0}
+                    onChange={(newValue) => onChange(newValue.toString())}
+                    currency={activeChannel?.defaultCurrencyCode ?? 'USD'}
+                />
+            );
+        }
+        return (
+            <Input
+                type="number"
+                placeholder={placeholder}
+                value={value}
+                onChange={e => onChange(e.target.value)}
+            />
+        );
+    };
+
+    return (
+        <div className="flex flex-col gap-2">
+            <div className="flex flex-col md:flex-row gap-2">
+                <Select value={operator} onValueChange={value => setOperator(value)}>
+                    <SelectTrigger>
+                        <SelectValue placeholder="Select operator" />
+                    </SelectTrigger>
+                    <SelectContent>
+                        {NUMBER_OPERATORS.map(op => (
+                            <SelectItem key={op} value={op}>
+                                <HumanReadableOperator operator={op} />
+                            </SelectItem>
+                        ))}
+                    </SelectContent>
+                </Select>
+                {operator !== 'isNull' && (
+                    operator === 'between' ? (
+                        <div className="flex gap-2">
+                            {renderInput(minValue, setMinValue, "Min")}
+                            {renderInput(maxValue, setMaxValue, "Max")}
+                        </div>
+                    ) : (
+                        renderInput(value, setValue, "Enter value...")
+                    )
+                )}
+            </div>
+            {error && <p className="text-sm text-red-500">{error}</p>}
+        </div>
+    );
+}

+ 62 - 0
packages/dashboard/src/lib/components/data-table/filters/data-table-string-filter.tsx

@@ -0,0 +1,62 @@
+import { Trans } from "@/lib/trans.js";
+
+import { Select, SelectValue, SelectTrigger, SelectItem } from "@/components/ui/select.js";
+
+import { SelectContent } from "@/components/ui/select.js";
+import { Input } from "@/components/ui/input.js";
+import { useEffect, useState } from "react";
+import { HumanReadableOperator } from "../human-readable-operator.js";
+
+export interface DataTableStringFilterProps {
+    value: Record<string, any> | undefined;
+    onChange: (filter: Record<string, any>) => void;
+}
+
+export const STRING_OPERATORS = ['eq', 'notEq', 'contains', 'notContains', 'in', 'notIn', 'regex', 'isNull'] as const;
+
+export function DataTableStringFilter({ value: incomingValue, onChange }: DataTableStringFilterProps) {
+    const initialOperator = incomingValue ? Object.keys(incomingValue)[0] : 'contains';
+    const initialValue = incomingValue ? Object.values(incomingValue)[0] : '';
+    const [operator, setOperator] = useState<string>(initialOperator ?? 'contains');
+    const [value, setValue] = useState((initialValue as string) ?? '');
+
+    useEffect(() => {
+        if (operator === 'isNull') {
+            onChange({ [operator]: true });
+        } else if (operator === 'in' || operator === 'notIn') {
+            // Split by comma and trim whitespace
+            if (typeof value === 'string') {
+                const values = value.split(',').map(v => v.trim()).filter(v => v);
+                onChange({ [operator]: values });
+            } else {
+                onChange({ [operator]: [] });
+            }
+        } else {
+            onChange({ [operator]: value });
+        }
+    }, [operator, value]);
+
+    return (
+        <div className="flex flex-col md:flex-row gap-2">
+            <Select value={operator} onValueChange={value => setOperator(value)}>
+                <SelectTrigger>
+                    <SelectValue placeholder="Select operator" />
+                </SelectTrigger>
+                <SelectContent>
+                    {STRING_OPERATORS.map(op => (
+                        <SelectItem key={op} value={op}>
+                            <HumanReadableOperator operator={op} />
+                        </SelectItem>
+                    ))}
+                </SelectContent>
+            </Select>
+            {operator !== 'isNull' && (
+                <Input
+                    placeholder={operator === 'in' || operator === 'notIn' ? "Enter comma-separated values..." : "Enter filter value..."}
+                    value={value}
+                    onChange={e => setValue(e.target.value)}
+                />
+            )}
+        </div>
+    )
+}

+ 65 - 0
packages/dashboard/src/lib/components/data-table/human-readable-operator.tsx

@@ -0,0 +1,65 @@
+import { DATETIME_OPERATORS } from './filters/data-table-datetime-filter.js';
+import { BOOLEAN_OPERATORS } from './filters/data-table-boolean-filter.js';
+import { ID_OPERATORS } from './filters/data-table-id-filter.js';
+import { NUMBER_OPERATORS } from './filters/data-table-number-filter.js';
+import { STRING_OPERATORS } from './filters/data-table-string-filter.js';
+import { Trans } from '@/lib/trans.js';
+
+type Operator =
+    | (typeof DATETIME_OPERATORS)[number]
+    | (typeof BOOLEAN_OPERATORS)[number]
+    | (typeof ID_OPERATORS)[number]
+    | (typeof NUMBER_OPERATORS)[number]
+    | (typeof STRING_OPERATORS)[number];
+
+export function HumanReadableOperator({
+    operator,
+    mode = 'long',
+}: {
+    operator: Operator;
+    mode?: 'short' | 'long';
+}) {
+    switch (operator) {
+        case 'eq':
+            return mode === 'short' ? <Trans>=</Trans> : <Trans>is equal to</Trans>;
+        case 'notEq':
+            return mode === 'short' ? <Trans>!=</Trans> : <Trans>is not equal to</Trans>;
+        case 'before':
+            return mode === 'short' ? <Trans>before</Trans> : <Trans>is before</Trans>;
+        case 'after':
+            return mode === 'short' ? <Trans>after</Trans> : <Trans>is after</Trans>;
+        case 'between':
+            return mode === 'short' ? <Trans>between</Trans> : <Trans>is between</Trans>;
+        case 'isNull':
+            return mode === 'short' ? <Trans>is null</Trans> : <Trans>is null</Trans>;
+        case 'in':
+            return mode === 'short' ? <Trans>in</Trans> : <Trans>is in</Trans>;
+        case 'notIn':
+            return mode === 'short' ? <Trans>not in</Trans> : <Trans>is not in</Trans>;
+        case 'gt':
+            return mode === 'short' ? <Trans>greater than</Trans> : <Trans>is greater than</Trans>;
+        case 'gte':
+            return mode === 'short' ? (
+                <Trans>greater than or equal</Trans>
+            ) : (
+                <Trans>is greater than or equal to</Trans>
+            );
+        case 'lt':
+            return mode === 'short' ? <Trans>less than</Trans> : <Trans>is less than</Trans>;
+        case 'lte':
+            return mode === 'short' ? (
+                <Trans>less than or equal</Trans>
+            ) : (
+                <Trans>is less than or equal to</Trans>
+            );
+        case 'contains':
+            return mode === 'short' ? <Trans>contains</Trans> : <Trans>contains</Trans>;
+        case 'notContains':
+            return mode === 'short' ? <Trans>does not contain</Trans> : <Trans>does not contain</Trans>;
+        case 'regex':
+            return mode === 'short' ? <Trans>matches regex</Trans> : <Trans>matches regex</Trans>;
+        default:
+            operator satisfies never;
+            return <Trans>{operator}</Trans>;
+    }
+}

+ 4 - 4
packages/dashboard/src/lib/components/layout/nav-user.tsx

@@ -44,14 +44,14 @@ export function NavUser() {
         });
     };
 
+    const avatarFallback = useMemo(() => {
+        return user?.firstName?.charAt(0) ?? '' + user?.lastName?.charAt(0) ?? '';
+    }, [user]);
+
     if (!user) {
         return <></>;
     }
 
-    const avatarFallback = useMemo(() => {
-        return user.firstName.charAt(0) + user.lastName.charAt(0);
-    }, [user]);
-
     const isDevMode = (import.meta as any).env?.MODE === 'development';
 
     return (

+ 0 - 345
packages/dashboard/src/lib/components/shared/asset-preview.tsx

@@ -1,345 +0,0 @@
-import { Button } from '@/components/ui/button.js';
-import { Card, CardContent, CardHeader } from '@/components/ui/card.js';
-import { FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form.js';
-import { Input } from '@/components/ui/input.js';
-import { Label } from '@/components/ui/label.js';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
-import { VendureImage } from '@/components/shared/vendure-image.js';
-import { AssetFragment } from '@/graphql/fragments.js';
-import { cn, formatFileSize } from '@/lib/utils.js';
-import { ChevronLeft, ChevronRight, Crosshair, Edit, ExternalLink, X } from 'lucide-react';
-import { useEffect, useRef, useState } from 'react';
-import { useForm } from 'react-hook-form';
-import { FocalPointControl } from './focal-point-control.js';
-
-export type PreviewPreset = 'tiny' | 'thumb' | 'small' | 'medium' | 'large' | '';
-
-interface Point {
-    x: number;
-    y: number;
-}
-
-export type AssetWithTags = AssetFragment & { tags?: { value: string }[] };
-
-interface AssetPreviewProps {
-    asset: AssetWithTags;
-    assets?: AssetWithTags[];
-    editable?: boolean;
-    customFields?: any[];
-    onAssetChange?: (asset: Partial<AssetWithTags>) => void;
-    onEditClick?: () => void;
-}
-
-export function AssetPreview({
-    asset,
-    assets,
-    editable = false,
-    customFields = [],
-    onAssetChange,
-    onEditClick,
-}: AssetPreviewProps) {
-    const [size, setSize] = useState<PreviewPreset>('medium');
-    const [width, setWidth] = useState(0);
-    const [height, setHeight] = useState(0);
-    const [centered, setCentered] = useState(true);
-    const [settingFocalPoint, setSettingFocalPoint] = useState(false);
-    const [lastFocalPoint, setLastFocalPoint] = useState<Point>();
-    const [assetIndex, setAssetIndex] = useState(assets?.indexOf(asset) || 0);
-
-    const imageRef = useRef<HTMLImageElement>(null);
-    const containerRef = useRef<HTMLDivElement>(null);
-    const sizePriorToFocalPoint = useRef<PreviewPreset>('medium');
-
-    const form = useForm({
-        defaultValues: {
-            name: asset.name,
-            tags: asset.tags?.map(t => t.value) || [],
-        },
-    });
-    const activeAsset = assets?.[assetIndex] ?? asset;
-
-    useEffect(() => {
-        if (assets?.length) {
-            const index = assets.findIndex(a => a.id === asset.id);
-            setAssetIndex(index === -1 ? 0 : index);
-        }
-    }, [assets, asset.id]);
-
-    useEffect(() => {
-        const handleResize = () => {
-            updateDimensions();
-        };
-
-        window.addEventListener('resize', handleResize);
-        return () => window.removeEventListener('resize', handleResize);
-    }, []);
-
-    const updateDimensions = () => {
-        if (!imageRef.current || !containerRef.current) return;
-
-        const img = imageRef.current;
-        const container = containerRef.current;
-        const imgWidth = img.naturalWidth;
-        const imgHeight = img.naturalHeight;
-        const containerWidth = container.offsetWidth;
-        const containerHeight = container.offsetHeight;
-
-        if (settingFocalPoint) {
-            const controlsMarginPx = 48 * 2;
-            const availableHeight = containerHeight - controlsMarginPx;
-            const availableWidth = containerWidth;
-            const hRatio = imgHeight / availableHeight;
-            const wRatio = imgWidth / availableWidth;
-
-            if (1 < hRatio || 1 < wRatio) {
-                const factor = hRatio < wRatio ? wRatio : hRatio;
-                setWidth(Math.round(imgWidth / factor));
-                setHeight(Math.round(imgHeight / factor));
-                setCentered(true);
-                return;
-            }
-        }
-
-        setWidth(imgWidth);
-        setHeight(imgHeight);
-        setCentered(imgWidth <= containerWidth && imgHeight <= containerHeight);
-    };
-
-    const handleFocalPointStart = () => {
-        sizePriorToFocalPoint.current = size;
-        setSize('medium');
-        setSettingFocalPoint(true);
-        setLastFocalPoint(asset.focalPoint || { x: 0.5, y: 0.5 });
-        updateDimensions();
-    };
-
-    const handleFocalPointChange = (point: Point) => {
-        setLastFocalPoint(point);
-    };
-
-    const handleFocalPointCancel = () => {
-        setSettingFocalPoint(false);
-        setLastFocalPoint(undefined);
-        setSize(sizePriorToFocalPoint.current);
-    };
-
-    const handleFocalPointSet = async () => {
-        if (!lastFocalPoint) return;
-
-        try {
-            // TODO: Implement API call to update focal point
-            await onAssetChange?.({
-                id: asset.id,
-                focalPoint: lastFocalPoint,
-            });
-            setSettingFocalPoint(false);
-            setSize(sizePriorToFocalPoint.current);
-            // Show success toast
-        } catch (err) {
-            // Show error toast
-        }
-    };
-
-    const handleRemoveFocalPoint = async () => {
-        try {
-            // TODO: Implement API call to remove focal point
-            await onAssetChange?.({
-                id: asset.id,
-                focalPoint: null,
-            });
-            // Show success toast
-        } catch (err) {
-            // Show error toast
-        }
-    };
-
-    return (
-        <div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-4 h-full">
-            <div className="space-y-4">
-                <Card>
-                    <CardContent className="space-y-4 pt-6">
-                        {!editable && onEditClick && (
-                            <Button variant="ghost" className="w-full justify-start" onClick={onEditClick}>
-                                <Edit className="mr-2 h-4 w-4" />
-                                Edit
-                                <ChevronRight className="ml-auto h-4 w-4" />
-                            </Button>
-                        )}
-
-                        {editable ? (
-                            <FormField
-                                control={form.control}
-                                name="name"
-                                render={({ field }) => (
-                                    <FormItem>
-                                        <FormLabel>Name</FormLabel>
-                                        <FormControl>
-                                            <Input {...field} />
-                                        </FormControl>
-                                    </FormItem>
-                                )}
-                            />
-                        ) : (
-                            <div>
-                                <Label>Name</Label>
-                                <p className="truncate text-sm text-muted-foreground">{activeAsset.name}</p>
-                            </div>
-                        )}
-
-                        <div>
-                            <Label>Source File</Label>
-                            <a
-                                href={activeAsset.source}
-                                target="_blank"
-                                rel="noopener noreferrer"
-                                className="text-sm text-primary hover:underline flex items-center"
-                            >
-                                {activeAsset.source.split('/').pop()}
-                                <ExternalLink className="ml-1 h-3 w-3" />
-                            </a>
-                        </div>
-
-                        <div>
-                            <Label>File Size</Label>
-                            <p className="text-sm text-muted-foreground">
-                                {formatFileSize(activeAsset.fileSize)}
-                            </p>
-                        </div>
-
-                        <div>
-                            <Label>Dimensions</Label>
-                            <p className="text-sm text-muted-foreground">
-                                {activeAsset.width} x {activeAsset.height}
-                            </p>
-                        </div>
-
-                        <div>
-                            <Label>Focal Point</Label>
-                            <div className="space-y-2">
-                                <p className="text-sm text-muted-foreground">
-                                    {activeAsset.focalPoint ? (
-                                        <span className="flex items-center">
-                                            <Crosshair className="mr-1 h-4 w-4" />
-                                            x: {activeAsset.focalPoint.x.toFixed(2)}, y:{' '}
-                                            {activeAsset.focalPoint.y.toFixed(2)}
-                                        </span>
-                                    ) : (
-                                        'Not set'
-                                    )}
-                                </p>
-                                <div className="flex gap-2">
-                                    <Button
-                                        variant="secondary"
-                                        size="sm"
-                                        disabled={settingFocalPoint}
-                                        onClick={handleFocalPointStart}
-                                    >
-                                        {activeAsset.focalPoint ? 'Update' : 'Set'} Focal Point
-                                    </Button>
-                                    {activeAsset.focalPoint && (
-                                        <Button
-                                            variant="secondary"
-                                            size="sm"
-                                            disabled={settingFocalPoint}
-                                            onClick={handleRemoveFocalPoint}
-                                        >
-                                            Remove
-                                        </Button>
-                                    )}
-                                </div>
-                            </div>
-                        </div>
-                    </CardContent>
-                </Card>
-
-                <Card>
-                    <CardHeader>Preview Options</CardHeader>
-                    <CardContent className="space-y-4">
-                        <Select value={size} onValueChange={value => setSize(value as PreviewPreset)}>
-                            <SelectTrigger>
-                                <SelectValue placeholder="Select size" />
-                            </SelectTrigger>
-                            <SelectContent>
-                                <SelectItem value="tiny">Tiny</SelectItem>
-                                <SelectItem value="thumb">Thumb</SelectItem>
-                                <SelectItem value="small">Small</SelectItem>
-                                <SelectItem value="medium">Medium</SelectItem>
-                                <SelectItem value="large">Large</SelectItem>
-                                <SelectItem value="full">Full Size</SelectItem>
-                            </SelectContent>
-                        </Select>
-                        <p className="text-sm text-muted-foreground">
-                            {width} x {height}
-                        </p>
-                    </CardContent>
-                </Card>
-            </div>
-
-            <div className="relative flex items-center justify-center bg-muted/30 rounded-lg">
-                {assets && assets.length > 1 && (
-                    <>
-                        <Button
-                            variant="ghost"
-                            size="icon"
-                            className="absolute left-4 z-10"
-                            onClick={() => setAssetIndex(i => i - 1)}
-                            disabled={assetIndex === 0}
-                        >
-                            <ChevronLeft className="h-4 w-4" />
-                        </Button>
-                        <Button
-                            variant="ghost"
-                            size="icon"
-                            className="absolute right-4 z-10"
-                            onClick={() => setAssetIndex(i => i + 1)}
-                            disabled={assetIndex === assets.length - 1}
-                        >
-                            <ChevronRight className="h-4 w-4" />
-                        </Button>
-                    </>
-                )}
-
-                <div
-                    ref={containerRef}
-                    className={cn(
-                        'relative',
-                        centered && 'flex items-center justify-center',
-                        settingFocalPoint && 'cursor-crosshair',
-                    )}
-                >
-                    <div className="relative" style={{ width: `${width}px`, height: `${height}px` }}>
-                        <VendureImage
-                            ref={imageRef}
-                            asset={activeAsset}
-                            preset={size || undefined}
-                            mode="resize"
-                            onLoad={updateDimensions}
-                            className="max-w-full max-h-full object-contain"
-                        />
-                        {settingFocalPoint && lastFocalPoint && (
-                            <FocalPointControl
-                                width={width}
-                                height={height}
-                                point={lastFocalPoint}
-                                onChange={handleFocalPointChange}
-                            />
-                        )}
-                    </div>
-
-                    {settingFocalPoint && (
-                        <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
-                            <Button variant="secondary" onClick={handleFocalPointCancel}>
-                                <X className="mr-2 h-4 w-4" />
-                                Cancel
-                            </Button>
-                            <Button onClick={handleFocalPointSet}>
-                                <Crosshair className="mr-2 h-4 w-4" />
-                                Set Focal Point
-                            </Button>
-                        </div>
-                    )}
-                </div>
-            </div>
-        </div>
-    );
-}

+ 93 - 0
packages/dashboard/src/lib/components/shared/asset/asset-focal-point-editor.tsx

@@ -0,0 +1,93 @@
+import { Button } from "@/components/ui/button.js";
+import { cn } from "@/lib/utils.js";
+import { Crosshair, X } from "lucide-react";
+import { useRef, useState } from "react";
+import { DndContext, useDraggable } from "@dnd-kit/core";
+import { restrictToParentElement } from "@dnd-kit/modifiers";
+import { CSS } from "@dnd-kit/utilities";
+import { Trans } from "@/lib/trans.js";
+
+export interface AssetFocalPointEditorProps {
+    settingFocalPoint: boolean;
+    focalPoint: Point | undefined;
+    width: number;
+    height: number;
+    onFocalPointChange: (point: Point) => void;
+    onCancel: () => void;
+    children?: React.ReactNode;
+}
+
+interface Point {
+    x: number;
+    y: number;
+}
+
+function DraggableFocalPoint({ point }: { point: Point }) {
+    const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
+        id: "focal-point",
+    });
+    const style = {
+        left: `${point.x * 100}%`,
+        top: `${point.y * 100}%`,
+        transform: isDragging ? `translate(-50%, -50%) ${CSS.Translate.toString(transform)}` : `translate(-50%, -50%)`,
+    };
+
+    return (
+        <div
+            ref={setNodeRef}
+            style={style}
+            {...listeners}
+            {...attributes}
+            className="absolute w-8 h-8 rounded-full border-4 border-white bg-brand/20 shadow-lg cursor-move"
+        />
+    );
+}
+
+export function AssetFocalPointEditor({ settingFocalPoint, focalPoint, width, height, onFocalPointChange, onCancel, children }: AssetFocalPointEditorProps) {
+
+    const [focalPointCurrent, setFocalPointCurrent] = useState<Point>(focalPoint ?? { x: 0.5, y: 0.5 });
+
+    const handleDragEnd = (event: any) => {
+        const { delta } = event;
+        const newX = Math.max(0, Math.min(1, focalPointCurrent.x + delta.x / width));
+        const newY = Math.max(0, Math.min(1, focalPointCurrent.y + delta.y / height));
+        const newPoint = { x: newX, y: newY };
+        setFocalPointCurrent(newPoint);
+    };
+
+    return (
+        <div
+            className={cn(
+                'relative',
+                'flex items-center justify-center',
+                settingFocalPoint && 'cursor-crosshair',
+            )}
+        >
+            <div className="relative" style={{ width: `${width}px`, height: `${height}px` }}>
+                {children}
+                {settingFocalPoint && (
+                    <DndContext onDragEnd={handleDragEnd} modifiers={[restrictToParentElement]}>
+                        <DraggableFocalPoint
+                            point={focalPointCurrent}
+                        />
+                    </DndContext>
+                )}
+            </div>
+
+            {settingFocalPoint && (
+                <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
+                    <Button type="button" variant="secondary" onClick={onCancel}>
+                        <X className="mr-2 h-4 w-4" />
+                        <Trans>Cancel</Trans>
+                    </Button>
+                    <Button type="button" onClick={() => {
+                        onFocalPointChange(focalPointCurrent);
+                    }}>
+                        <Crosshair className="mr-2 h-4 w-4" />
+                        <Trans>Set Focal Point</Trans>
+                    </Button>
+                </div>
+            )}
+        </div>
+    );
+}

+ 51 - 20
packages/dashboard/src/lib/components/shared/asset-gallery.tsx → packages/dashboard/src/lib/components/shared/asset/asset-gallery.tsx

@@ -23,6 +23,7 @@ import { Loader2, Search, Upload, X } from 'lucide-react';
 import { useCallback, useState } from 'react';
 import { useDropzone } from 'react-dropzone';
 import { useDebounce } from '@uidotdev/usehooks';
+import { DetailPageButton } from '../detail-page-button.js';
 
 const getAssetListDocument = graphql(
     `
@@ -72,7 +73,14 @@ export type Asset = AssetFragment;
 export interface AssetGalleryProps {
     onSelect?: (assets: Asset[]) => void;
     selectable?: boolean;
-    multiSelect?: boolean;
+    /**
+     * @description
+     * Defines whether multiple assets can be selected.
+     * 
+     * If set to 'auto', the asset selection will be toggled when the user clicks on an asset.
+     * If set to 'manual', multiple selection will occur only if the user holds down the control/cmd key.
+     */
+    multiSelect?: 'auto' | 'manual';
     initialSelectedAssets?: Asset[];
     pageSize?: number;
     fixedHeight?: boolean;
@@ -84,7 +92,7 @@ export interface AssetGalleryProps {
 export function AssetGallery({
     onSelect,
     selectable = true,
-    multiSelect = false,
+    multiSelect = undefined,
     initialSelectedAssets = [],
     pageSize = 24,
     fixedHeight = false,
@@ -149,24 +157,44 @@ export function AssetGallery({
     const totalPages = Math.ceil(totalItems / pageSize);
 
     // Handle selection
-    const handleSelect = (asset: Asset) => {
-        if (!multiSelect) {
-            setSelected([asset]);
-            onSelect?.([asset]);
+    const handleSelect = (asset: Asset, event: React.MouseEvent) => {
+        if (multiSelect === 'auto') {
+            const isSelected = selected.some(a => a.id === asset.id);
+            let newSelected: Asset[];
+
+            if (isSelected) {
+                newSelected = selected.filter(a => a.id !== asset.id);
+            } else {
+                newSelected = [...selected, asset];
+            }
+
+            setSelected(newSelected);
+            onSelect?.(newSelected);
             return;
         }
 
-        const isSelected = selected.some(a => a.id === asset.id);
-        let newSelected: Asset[];
 
-        if (isSelected) {
-            newSelected = selected.filter(a => a.id !== asset.id);
+        // Manual mode - check for modifier key
+        const isModifierKeyPressed = event.metaKey || event.ctrlKey;
+
+        if (multiSelect === 'manual' && isModifierKeyPressed) {
+            // Toggle selection
+            const isSelected = selected.some(a => a.id === asset.id);
+            let newSelected: Asset[];
+
+            if (isSelected) {
+                newSelected = selected.filter(a => a.id !== asset.id);
+            } else {
+                newSelected = [...selected, asset];
+            }
+
+            setSelected(newSelected);
+            onSelect?.(newSelected);
         } else {
-            newSelected = [...selected, asset];
+            // No modifier key - single select
+            setSelected([asset]);
+            onSelect?.([asset]);
         }
-
-        setSelected(newSelected);
-        onSelect?.(newSelected);
     };
 
     // Check if an asset is selected
@@ -272,7 +300,7 @@ export function AssetGallery({
                                     ${isSelected(asset as Asset) ? 'ring-2 ring-primary' : ''}
                                     flex flex-col min-w-[120px]
                                 `}
-                                onClick={() => handleSelect(asset as Asset)}
+                                onClick={(e) => handleSelect(asset as Asset, e)}
                             >
                                 <div
                                     className="relative w-full bg-muted/30"
@@ -296,11 +324,14 @@ export function AssetGallery({
                                     <p className="text-xs line-clamp-2 min-h-[2.5rem]" title={asset.name}>
                                         {asset.name}
                                     </p>
-                                    {asset.fileSize && (
-                                        <p className="text-xs text-muted-foreground mt-1">
-                                            {formatFileSize(asset.fileSize)}
-                                        </p>
-                                    )}
+                                    <div className='flex justify-between items-center'>
+                                        {asset.fileSize && (
+                                            <p className="text-xs text-muted-foreground mt-1">
+                                                {formatFileSize(asset.fileSize)}
+                                            </p>
+                                        )}
+                                        <DetailPageButton id={asset.id} label={<Trans>Edit</Trans>} />
+                                    </div>
                                 </CardContent>
                             </Card>
                         ))

+ 1 - 1
packages/dashboard/src/lib/components/shared/asset-picker-dialog.tsx → packages/dashboard/src/lib/components/shared/asset/asset-picker-dialog.tsx

@@ -47,7 +47,7 @@ export function AssetPickerDialog({
         <div className="flex-grow py-4">
           <AssetGallery 
             onSelect={handleAssetSelect}
-            multiSelect={multiSelect}
+            multiSelect='manual'
             initialSelectedAssets={initialSelectedAssets}
             fixedHeight={true}
           />

+ 1 - 7
packages/dashboard/src/lib/components/shared/asset-preview-dialog.tsx → packages/dashboard/src/lib/components/shared/asset/asset-preview-dialog.tsx

@@ -12,9 +12,7 @@ interface AssetPreviewDialogProps {
     onOpenChange: (open: boolean) => void;
     asset: AssetWithTags;
     assets?: AssetWithTags[];
-    editable?: boolean;
     customFields?: any[];
-    onAssetChange?: (asset: Partial<AssetWithTags>) => void;
 }
 
 export function AssetPreviewDialog({
@@ -22,24 +20,20 @@ export function AssetPreviewDialog({
     onOpenChange,
     asset,
     assets,
-    editable,
     customFields,
-    onAssetChange,
 }: AssetPreviewDialogProps) {
     return (
         <Dialog open={open} onOpenChange={onOpenChange}>
             <DialogContent className="sm:max-w-[800px] lg:max-w-[95vw] w-[95vw] p-0">
                 <DialogHeader className="p-6 pb-0">
                     <DialogTitle>Asset</DialogTitle>
-                    <DialogDescription>Description goes here</DialogDescription>
+                    <DialogDescription>Preview of {asset.name}</DialogDescription>
                 </DialogHeader>
                 <div className="h-full p-6">
                     <AssetPreview
                         asset={asset}
                         assets={assets}
-                        editable={editable}
                         customFields={customFields}
-                        onAssetChange={onAssetChange}
                     />
                 </div>
             </DialogContent>

+ 34 - 0
packages/dashboard/src/lib/components/shared/asset/asset-preview-selector.tsx

@@ -0,0 +1,34 @@
+
+
+import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select.js";
+import { PreviewPreset } from "./asset-preview.js";
+
+export interface AssetPreviewSelectorProps {
+    size: PreviewPreset;
+    setSize: (size: PreviewPreset) => void;
+    width: number;
+    height: number;
+}
+
+export function AssetPreviewSelector({ size, setSize, width, height }: AssetPreviewSelectorProps) {
+    return (
+        <div className="flex items-center gap-2">
+            <Select value={size} onValueChange={value => setSize(value as PreviewPreset)}>
+                <SelectTrigger>
+                    <SelectValue placeholder="Select size" />
+                </SelectTrigger>
+                <SelectContent>
+                    <SelectItem value="tiny">Tiny</SelectItem>
+                    <SelectItem value="thumb">Thumb</SelectItem>
+                    <SelectItem value="small">Small</SelectItem>
+                    <SelectItem value="medium">Medium</SelectItem>
+                    <SelectItem value="large">Large</SelectItem>
+                    <SelectItem value="full">Full Size</SelectItem>
+                </SelectContent>
+            </Select>
+            <p className="text-sm text-muted-foreground">
+                {width} x {height}
+            </p>
+        </div>
+    );
+}

+ 128 - 0
packages/dashboard/src/lib/components/shared/asset/asset-preview.tsx

@@ -0,0 +1,128 @@
+import { VendureImage } from '@/components/shared/vendure-image.js';
+import { Button } from '@/components/ui/button.js';
+import { Card, CardContent } from '@/components/ui/card.js';
+import { AssetFragment } from '@/graphql/fragments.js';
+import { cn } from '@/lib/utils.js';
+import { ChevronLeft, ChevronRight } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { AssetPreviewSelector } from './asset-preview-selector.js';
+import { AssetProperties } from './asset-properties.js';
+
+export type PreviewPreset = 'tiny' | 'thumb' | 'small' | 'medium' | 'large' | '';
+
+
+export type AssetWithTags = AssetFragment & { tags?: { value: string }[] };
+
+interface AssetPreviewProps {
+    asset: AssetWithTags;
+    assets?: AssetWithTags[];
+    customFields?: any[];
+}
+
+export function AssetPreview({
+    asset,
+    assets,
+    customFields = [],
+}: AssetPreviewProps) {
+    const [size, setSize] = useState<PreviewPreset>('medium');
+    const [width, setWidth] = useState(0);
+    const [height, setHeight] = useState(0);
+    const [centered, setCentered] = useState(true);
+    const [assetIndex, setAssetIndex] = useState(assets?.indexOf(asset) || 0);
+
+    const imageRef = useRef<HTMLImageElement>(null);
+    const containerRef = useRef<HTMLDivElement>(null);
+
+    const form = useForm({
+        defaultValues: {
+            name: asset.name,
+            tags: asset.tags?.map(t => t.value) || [],
+        },
+    });
+    const activeAsset = assets?.[assetIndex] ?? asset;
+
+    useEffect(() => {
+        if (assets?.length) {
+            const index = assets.findIndex(a => a.id === asset.id);
+            setAssetIndex(index === -1 ? 0 : index);
+        }
+    }, [assets, asset.id]);
+
+    useEffect(() => {
+        const handleResize = () => {
+            updateDimensions();
+        };
+
+        window.addEventListener('resize', handleResize);
+        return () => window.removeEventListener('resize', handleResize);
+    }, []);
+
+    const updateDimensions = () => {
+        if (!imageRef.current || !containerRef.current) return;
+
+        const img = imageRef.current;
+        const container = containerRef.current;
+        const imgWidth = img.naturalWidth;
+        const imgHeight = img.naturalHeight;
+        const containerWidth = container.offsetWidth;
+        const containerHeight = container.offsetHeight;
+        setWidth(imgWidth);
+        setHeight(imgHeight);
+        setCentered(imgWidth <= containerWidth && imgHeight <= containerHeight);
+    };
+
+    return (
+        <div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-4 h-full">
+            <div className="space-y-4">
+                <Card>
+                    <CardContent className="pt-6 space-y-4">
+                        <AssetProperties asset={activeAsset} />
+                        <AssetPreviewSelector size={size} setSize={setSize} width={width} height={height} />
+                    </CardContent>
+                </Card>
+            </div>
+
+            <div className="relative flex items-center justify-center bg-muted/30 rounded-lg">
+                {assets && assets.length > 1 && (
+                    <>
+                        <Button
+                            variant="ghost"
+                            size="icon"
+                            className="absolute left-4 z-10"
+                            onClick={() => setAssetIndex(i => i - 1)}
+                            disabled={assetIndex === 0}
+                        >
+                            <ChevronLeft className="h-4 w-4" />
+                        </Button>
+                        <Button
+                            variant="ghost"
+                            size="icon"
+                            className="absolute right-4 z-10"
+                            onClick={() => setAssetIndex(i => i + 1)}
+                            disabled={assetIndex === assets.length - 1}
+                        >
+                            <ChevronRight className="h-4 w-4" />
+                        </Button>
+                    </>
+                )}
+                <div
+                    ref={containerRef}
+                    className={cn(
+                        'relative',
+                        centered && 'flex items-center justify-center',
+                    )}
+                >
+                    <VendureImage
+                        ref={imageRef}
+                        asset={activeAsset}
+                        preset={size || undefined}
+                        mode="resize"
+                        onLoad={updateDimensions}
+                        className="max-w-full max-h-full object-contain"
+                    />
+                </div>
+            </div>
+        </div>
+    );
+}

+ 46 - 0
packages/dashboard/src/lib/components/shared/asset/asset-properties.tsx

@@ -0,0 +1,46 @@
+import { formatFileSize } from "@/lib/utils.js";
+
+import { Label } from "@/components/ui/label.js";
+import { AssetFragment } from "@/graphql/fragments.js";
+import { ExternalLink } from "lucide-react";
+
+export interface AssetPropertiesProps {
+    asset: AssetFragment;
+}
+
+export function AssetProperties({ asset }: AssetPropertiesProps) {
+    return (
+        <div className="space-y-4">
+            <div>
+                <Label>Name</Label>
+                <p className="truncate text-sm text-muted-foreground">{asset.name}</p>
+            </div>
+            <div>
+                <Label>Source File</Label>
+                <a
+                    href={asset.source}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="text-sm text-primary hover:underline"
+                >
+                    {asset.source.split('/').pop()}
+                    <ExternalLink className="ml-1 h-3 w-3 inline" />
+                </a>
+            </div>
+
+            <div>
+                <Label>File Size</Label>
+                <p className="text-sm text-muted-foreground">
+                    {formatFileSize(asset.fileSize)}
+                </p>
+            </div>
+
+            <div>
+                <Label>Dimensions</Label>
+                <p className="text-sm text-muted-foreground">
+                    {asset.width} x {asset.height}
+                </p>
+            </div>
+        </div>
+    );
+}

+ 1 - 1
packages/dashboard/src/lib/components/shared/focal-point-control.tsx → packages/dashboard/src/lib/components/shared/asset/focal-point-control.tsx

@@ -1,7 +1,7 @@
 import { useEffect, useState } from 'react';
 import { cn } from '@/lib/utils.js';
 
-interface Point {
+export interface Point {
     x: number;
     y: number;
 }

+ 4 - 3
packages/dashboard/src/lib/components/shared/custom-fields-form.tsx

@@ -22,9 +22,10 @@ type CustomFieldConfig = ResultOf<typeof customFieldConfigFragment>;
 interface CustomFieldsFormProps {
     entityType: string;
     control: Control<any, any>;
+    formPathPrefix?: string;
 }
 
-export function CustomFieldsForm({ entityType, control }: CustomFieldsFormProps) {
+export function CustomFieldsForm({ entityType, control, formPathPrefix }: CustomFieldsFormProps) {
     const {
         settings: { displayLanguage },
     } = useUserSettings();
@@ -39,7 +40,7 @@ export function CustomFieldsForm({ entityType, control }: CustomFieldsFormProps)
                     {fieldDef.type === 'localeString' || fieldDef.type === 'localeText' ? (
                         <TranslatableFormField
                             control={control}
-                            name={`customFields.${fieldDef.name}`}
+                            name={formPathPrefix ? `${formPathPrefix}.customFields.${fieldDef.name}` : `customFields.${fieldDef.name}`}
                             render={({ field }) => (
                                 <FormItem>
                                     <FormLabel>{getTranslation(fieldDef.label) ?? field.name}</FormLabel>
@@ -52,7 +53,7 @@ export function CustomFieldsForm({ entityType, control }: CustomFieldsFormProps)
                     ) : (
                         <FormField
                             control={control}
-                            name={`customFields.${fieldDef.name}`}
+                            name={formPathPrefix ? `${formPathPrefix}.customFields.${fieldDef.name}` : `customFields.${fieldDef.name}`}
                             render={({ field }) => (
                             <FormItem>
                                 <FormLabel>{getTranslation(fieldDef.label) ?? field.name}</FormLabel>

+ 13 - 14
packages/dashboard/src/lib/components/shared/customer-selector.tsx

@@ -5,6 +5,7 @@ import { api } from '@/graphql/api.js';
 import { graphql } from '@/graphql/graphql.js';
 import { Trans } from '@/lib/trans.js';
 import { useQuery } from '@tanstack/react-query';
+import { useDebounce } from '@uidotdev/usehooks';
 import { Plus, Search } from 'lucide-react';
 import { useState } from 'react';
 
@@ -38,19 +39,20 @@ export interface CustomerSelectorProps {
 export function CustomerSelector(props: CustomerSelectorProps) {
     const [open, setOpen] = useState(false);
     const [searchTerm, setSearchTerm] = useState('');
+    const debouncedSearchTerm = useDebounce(searchTerm, 300);
 
     const { data, isLoading } = useQuery({
-        queryKey: ['customers', searchTerm],
+        queryKey: ['customers', debouncedSearchTerm],
         queryFn: () =>
             api.query(customersDocument, {
                 options: {
                     sort: { lastName: 'ASC' },
-                    filter: searchTerm ? {
-                        firstName: { contains: searchTerm },
-                        lastName: { contains: searchTerm },
-                        emailAddress: { contains: searchTerm },
+                    filter: debouncedSearchTerm ? {
+                        firstName: { contains: debouncedSearchTerm },
+                        lastName: { contains: debouncedSearchTerm },
+                        emailAddress: { contains: debouncedSearchTerm },
                     } : undefined,
-                    filterOperator: searchTerm ? 'OR' : undefined,
+                    filterOperator: debouncedSearchTerm ? 'OR' : undefined,
                 },
             }),
         staleTime: 1000 * 60, // 1 minute
@@ -70,14 +72,11 @@ export function CustomerSelector(props: CustomerSelectorProps) {
             </PopoverTrigger>
             <PopoverContent className="p-0 w-[350px]" align="start">
                 <Command shouldFilter={false}>
-                    <div className="flex items-center border-b px-3">
-                        <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
-                        <CommandInput 
-                            placeholder="Search customers..." 
-                            onValueChange={handleSearch}
-                            className="h-10 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
-                        />
-                    </div>
+                    <CommandInput
+                        placeholder="Search customers..."
+                        onValueChange={handleSearch}
+                        className="h-10 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
+                    />
                     <CommandList>
                         <CommandEmpty>
                             {isLoading ? (

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

@@ -1,6 +1,6 @@
 import { Link } from '@tanstack/react-router';
+import { ChevronRight } from 'lucide-react';
 import { Button } from '../ui/button.js';
-import { SquareArrowOutUpRightIcon } from 'lucide-react';
 
 export function DetailPageButton({
     id,
@@ -15,7 +15,7 @@ export function DetailPageButton({
         <Button asChild variant="ghost" disabled={disabled}>
             <Link to={`./${id}`}>
                 {label}
-                {!disabled && <SquareArrowOutUpRightIcon className="h-3 w-3 text-muted-foreground" />}
+                {!disabled && <ChevronRight className="h-3 w-3 text-muted-foreground" />}
             </Link>
         </Button>
     );

+ 3 - 3
packages/dashboard/src/lib/components/shared/entity-assets.tsx

@@ -24,9 +24,9 @@ import {
 } from '@dnd-kit/sortable';
 import { CSS } from '@dnd-kit/utilities';
 import { EllipsisIcon, ImageIcon, PaperclipIcon } from 'lucide-react';
-import React, { useCallback, useEffect, useState } from 'react';
-import { AssetPickerDialog } from './asset-picker-dialog.js';
-import { AssetPreviewDialog } from './asset-preview-dialog.js';
+import { useCallback, useEffect, useState } from 'react';
+import { AssetPickerDialog } from './asset/asset-picker-dialog.js';
+import { AssetPreviewDialog } from './asset/asset-preview-dialog.js';
 import { VendureImage } from './vendure-image.js';
 
 type Asset = AssetFragment;

+ 39 - 0
packages/dashboard/src/lib/components/shared/navigation-confirmation.tsx

@@ -0,0 +1,39 @@
+import { Trans } from "@/lib/trans.js";
+import { useBlocker } from "@tanstack/react-router";
+import { UseFormReturn } from "react-hook-form";
+
+import { Button } from "../ui/button.js";
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog.js";
+
+export interface NavigationConfirmationProps {
+    form: UseFormReturn<any>;
+}
+
+/**
+ * Navigation confirmation dialog that blocks navigation when the form is dirty.
+ */
+export function NavigationConfirmation(props: NavigationConfirmationProps) {
+    const { proceed, reset, status } = useBlocker({
+        shouldBlockFn: () => props.form.formState.isDirty,
+        withResolver: true,
+        enableBeforeUnload: true,
+    })
+    return (
+        <Dialog open={status === 'blocked'} onOpenChange={reset}>
+            <DialogContent className="sm:max-w-[425px]">
+                <DialogHeader>
+                    <DialogTitle><Trans>Confirm navigation</Trans></DialogTitle>
+                    <DialogDescription>
+                        <Trans>Are you sure you want to navigate away from this page? Any unsaved changes will be lost.</Trans>
+                    </DialogDescription>
+                </DialogHeader>
+                <DialogFooter>
+                    <Button variant="outline" onClick={reset}><Trans>Cancel</Trans></Button>
+                    <Button type="button" onClick={proceed}>
+                        <Trans>Confirm</Trans>
+                    </Button>
+                </DialogFooter>
+            </DialogContent>
+        </Dialog>
+    )
+}

+ 9 - 1
packages/dashboard/src/lib/components/shared/paginated-list-data-table.tsx

@@ -28,7 +28,7 @@ import {
     SortingState,
     Table,
 } from '@tanstack/react-table';
-import { AccessorKeyColumnDef, ColumnDef, Row, TableOptions } from '@tanstack/table-core';
+import { AccessorKeyColumnDef, ColumnDef, Row, TableOptions, VisibilityState } from '@tanstack/table-core';
 import { EllipsisIcon, TrashIcon } from 'lucide-react';
 import React, { useMemo } from 'react';
 import { toast } from 'sonner';
@@ -196,6 +196,7 @@ export interface PaginatedListDataTableProps<
     onPageChange: (table: Table<any>, page: number, perPage: number) => void;
     onSortChange: (table: Table<any>, sorting: SortingState) => void;
     onFilterChange: (table: Table<any>, filters: ColumnFiltersState) => void;
+    onColumnVisibilityChange?: (table: Table<any>, columnVisibility: VisibilityState) => void;
     facetedFilters?: FacetedFilterConfig<T>;
     rowActions?: RowAction<PaginatedListItemFields<T>>[];
     disableViewOptions?: boolean;
@@ -227,6 +228,7 @@ export function PaginatedListDataTable<
     onPageChange,
     onSortChange,
     onFilterChange,
+    onColumnVisibilityChange,
     facetedFilters,
     rowActions,
     disableViewOptions,
@@ -331,6 +333,10 @@ export function PaginatedListDataTable<
                 meta: { fieldInfo, isCustomField },
                 enableColumnFilter,
                 enableSorting: fieldInfo.isScalar,
+                // Filtering is done on the server side, but we set this to 'equalsString' because
+                // otherwise the TanStack Table with apply an "auto" function which somehow
+                // prevents certain filters from working.
+                filterFn: 'equalsString',
                 cell: ({ cell, row }) => {
                     const value = !isCustomField
                         ? cell.getValue()
@@ -416,6 +422,7 @@ export function PaginatedListDataTable<
                 onPageChange={onPageChange}
                 onSortChange={onSortChange}
                 onFilterChange={onFilterChange}
+                onColumnVisibilityChange={onColumnVisibilityChange}
                 onSearchTermChange={onSearchTermChange ? term => setSearchTerm(term) : undefined}
                 defaultColumnVisibility={columnVisibility}
                 facetedFilters={facetedFilters}
@@ -434,6 +441,7 @@ function getRowActions(
         id: 'actions',
         accessorKey: 'actions',
         header: 'Actions',
+        enableColumnFilter: false,
         cell: ({ row }) => {
             return (
                 <DropdownMenu>

+ 111 - 0
packages/dashboard/src/lib/components/shared/product-variant-selector.tsx

@@ -0,0 +1,111 @@
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command.js';
+import {
+    Popover,
+    PopoverContent,
+    PopoverTrigger,
+} from "@/components/ui/popover.js";
+import { api } from '@/graphql/api.js';
+import { graphql } from '@/graphql/graphql.js';
+import { cn } from '@/lib/utils.js';
+import { useQuery } from '@tanstack/react-query';
+import { useDebounce } from '@uidotdev/usehooks';
+import { ChevronsUpDown, Plus } from 'lucide-react';
+import { useState } from 'react';
+import { Button } from '../ui/button.js';
+import { assetFragment } from '@/graphql/fragments.js';
+import { VendureImage } from './vendure-image.js';
+
+const productVariantListDocument = graphql(`
+    query ProductVariantList($options: ProductVariantListOptions) {
+        productVariants(options: $options) {
+            items {
+                id
+                name
+                sku
+                featuredAsset {
+                    ...Asset
+                }
+            }
+            totalItems
+        }
+    }
+`, [assetFragment]);
+
+export interface ProductVariantSelectorProps {
+    onProductVariantIdChange: (productVariantId: string) => void;
+}
+
+export function ProductVariantSelector({ onProductVariantIdChange }: ProductVariantSelectorProps) {
+    const [search, setSearch] = useState('');
+    const [open, setOpen] = useState(false);
+    const debouncedSearch = useDebounce(search, 500);
+
+    const { data, isLoading } = useQuery({
+        queryKey: ['productVariants', debouncedSearch],
+        staleTime: 1000 * 60 * 5,
+        enabled: debouncedSearch.length > 0,
+        queryFn: () =>
+            api.query(productVariantListDocument, {
+                options: {
+                    take: 10,
+                    filter: {
+                        name: { contains: debouncedSearch },
+                        sku: { contains: debouncedSearch },
+                    },
+                    filterOperator: 'OR',
+                },
+            }),
+    });
+
+    return (
+        <Popover open={open} onOpenChange={setOpen}>
+            <PopoverTrigger asChild>
+                <Button
+                    variant="outline"
+                    role="combobox"
+                    className="w-full"
+                >
+                    Add item to order
+                    <Plus className="opacity-50" />
+                </Button>
+            </PopoverTrigger>
+            <PopoverContent className="p-0">
+                <Command shouldFilter={false}>
+                    <CommandInput
+                        placeholder="Add item to order..."
+                        className="h-9"
+                       onValueChange={(value) => setSearch(value)}
+                    />
+                    <CommandList>
+                        <CommandEmpty>No products found.</CommandEmpty>
+                        <CommandGroup>
+                            {data?.productVariants.items.map((variant) => (
+                                <CommandItem
+                                    key={variant.id}
+                                    value={variant.id}
+                                    onSelect={() => {
+                                        onProductVariantIdChange(variant.id);
+                                        setOpen(false);
+                                    }}
+                                    className="flex items-center gap-2 p-2"
+                                >
+                                    {variant.featuredAsset && (
+                                        <VendureImage
+                                            asset={variant.featuredAsset}
+                                            preset="tiny"
+                                            className="size-8 rounded-md object-cover"
+                                        />
+                                    )}
+                                    <div className="flex flex-col">
+                                        <span className="text-sm font-medium">{variant.name}</span>
+                                        <span className="text-xs text-muted-foreground">{variant.sku}</span>
+                                    </div>
+                                </CommandItem>
+                            ))}
+                        </CommandGroup>
+                    </CommandList>
+                </Command>
+            </PopoverContent>
+        </Popover>
+    );
+}

+ 1 - 1
packages/dashboard/src/lib/components/shared/vendure-image.tsx

@@ -70,7 +70,7 @@ export function VendureImage({
     }
 
     // Apply focal point if available and requested
-    if (useFocalPoint && asset.focalPoint && mode === 'crop') {
+    if (useFocalPoint && asset.focalPoint) {
         url.searchParams.set('fpx', asset.focalPoint.x.toString());
         url.searchParams.set('fpy', asset.focalPoint.y.toString());
     }

+ 508 - 63
packages/dashboard/src/lib/components/ui/calendar.tsx

@@ -1,69 +1,514 @@
-import * as React from 'react';
-import { ChevronLeft, ChevronRight } from 'lucide-react';
-import { DayPicker } from 'react-day-picker';
+"use client"
 
-import { cn } from '@/lib/utils.js';
-import { buttonVariants } from '@/components/ui/button.js';
+// A custom calendar that is compatible
+// with react-day-picker v9 from https://date-picker.luca-felix.com/
 
+import { Button, buttonVariants } from "@/components/ui/button.js"
+import { cn } from "@/lib/utils.js"
+import { differenceInCalendarDays } from "date-fns"
+import { ChevronLeft, ChevronRight } from "lucide-react"
+import * as React from "react"
+import {
+  DayPicker,
+  labelNext,
+  labelPrevious,
+  useDayPicker,
+  type DayPickerProps,
+} from "react-day-picker"
+
+export type CalendarProps = DayPickerProps & {
+  /**
+   * In the year view, the number of years to display at once.
+   * @default 12
+   */
+  yearRange?: number
+
+  /**
+   * Wether to show the year switcher in the caption.
+   * @default true
+   */
+  showYearSwitcher?: boolean
+
+  monthsClassName?: string
+  monthCaptionClassName?: string
+  weekdaysClassName?: string
+  weekdayClassName?: string
+  monthClassName?: string
+  captionClassName?: string
+  captionLabelClassName?: string
+  buttonNextClassName?: string
+  buttonPreviousClassName?: string
+  navClassName?: string
+  monthGridClassName?: string
+  weekClassName?: string
+  dayClassName?: string
+  dayButtonClassName?: string
+  rangeStartClassName?: string
+  rangeEndClassName?: string
+  selectedClassName?: string
+  todayClassName?: string
+  outsideClassName?: string
+  disabledClassName?: string
+  rangeMiddleClassName?: string
+  hiddenClassName?: string
+}
+
+type NavView = "days" | "years"
+
+/**
+ * A custom calendar component built on top of react-day-picker.
+ * @param props The props for the calendar.
+ * @default yearRange 12
+ * @returns
+ */
 function Calendar({
-    className,
-    classNames,
-    showOutsideDays = true,
-    ...props
-}: React.ComponentProps<typeof DayPicker>) {
-    return (
-        <DayPicker
-            showOutsideDays={showOutsideDays}
-            className={cn('p-3', className)}
-            classNames={{
-                months: 'flex flex-col sm:flex-row gap-2',
-                month: 'flex flex-col gap-4',
-                caption: 'flex justify-center pt-1 relative items-center w-full',
-                caption_label: 'text-sm font-medium',
-                nav: 'flex items-center gap-1',
-                nav_button: cn(
-                    buttonVariants({ variant: 'outline' }),
-                    'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
-                ),
-                nav_button_previous: 'absolute left-1',
-                nav_button_next: 'absolute right-1',
-                table: 'w-full border-collapse space-x-1',
-                head_row: 'flex',
-                head_cell: 'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
-                row: 'flex w-full mt-2',
-                cell: cn(
-                    'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md',
-                    props.mode === 'range'
-                        ? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
-                        : '[&:has([aria-selected])]:rounded-md',
-                ),
-                day: cn(
-                    buttonVariants({ variant: 'ghost' }),
-                    'size-8 p-0 font-normal aria-selected:opacity-100',
-                ),
-                day_range_start:
-                    'day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground',
-                day_range_end: 'day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground',
-                day_selected:
-                    'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
-                day_today: 'bg-accent text-accent-foreground',
-                day_outside: 'day-outside text-muted-foreground aria-selected:text-muted-foreground',
-                day_disabled: 'text-muted-foreground opacity-50',
-                day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
-                day_hidden: 'invisible',
-                ...classNames,
-            }}
-            components={{
-                IconLeft: ({ className, ...props }) => (
-                    <ChevronLeft className={cn('size-4', className)} {...props} />
-                ),
-                IconRight: ({ className, ...props }) => (
-                    <ChevronRight className={cn('size-4', className)} {...props} />
-                ),
-            }}
+  className,
+  showOutsideDays = true,
+  showYearSwitcher = true,
+  yearRange = 12,
+  numberOfMonths,
+  ...props
+}: CalendarProps) {
+  const [navView, setNavView] = React.useState<NavView>("days")
+  const [displayYears, setDisplayYears] = React.useState<{
+    from: number
+    to: number
+  }>(
+    React.useMemo(() => {
+      const currentYear = new Date().getFullYear()
+      return {
+        from: currentYear - Math.floor(yearRange / 2 - 1),
+        to: currentYear + Math.ceil(yearRange / 2),
+      }
+    }, [yearRange])
+  )
+
+  const { onNextClick, onPrevClick, startMonth, endMonth } = props
+
+  const columnsDisplayed = navView === "years" ? 1 : numberOfMonths
+
+  const _monthsClassName = cn("relative flex", props.monthsClassName)
+  const _monthCaptionClassName = cn(
+    "relative mx-10 flex h-7 items-center justify-center",
+    props.monthCaptionClassName
+  )
+  const _weekdaysClassName = cn("flex flex-row", props.weekdaysClassName)
+  const _weekdayClassName = cn(
+    "w-8 text-sm font-normal text-muted-foreground",
+    props.weekdayClassName
+  )
+  const _monthClassName = cn("w-full", props.monthClassName)
+  const _captionClassName = cn(
+    "relative flex items-center justify-center pt-1",
+    props.captionClassName
+  )
+  const _captionLabelClassName = cn(
+    "truncate text-sm font-medium",
+    props.captionLabelClassName
+  )
+  const buttonNavClassName = buttonVariants({
+    variant: "outline",
+    className:
+      "absolute h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
+  })
+  const _buttonNextClassName = cn(
+    buttonNavClassName,
+    "right-0",
+    props.buttonNextClassName
+  )
+  const _buttonPreviousClassName = cn(
+    buttonNavClassName,
+    "left-0",
+    props.buttonPreviousClassName
+  )
+  const _navClassName = cn("flex items-start", props.navClassName)
+  const _monthGridClassName = cn("mx-auto mt-4", props.monthGridClassName)
+  const _weekClassName = cn("mt-2 flex w-max items-start", props.weekClassName)
+  const _dayClassName = cn(
+    "flex size-8 flex-1 items-center justify-center p-0 text-sm",
+    props.dayClassName
+  )
+  const _dayButtonClassName = cn(
+    buttonVariants({ variant: "ghost" }),
+    "size-8 rounded-md p-0 font-normal transition-none aria-selected:opacity-100",
+    props.dayButtonClassName
+  )
+  const buttonRangeClassName =
+    "bg-accent [&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground"
+  const _rangeStartClassName = cn(
+    buttonRangeClassName,
+    "day-range-start rounded-s-md",
+    props.rangeStartClassName
+  )
+  const _rangeEndClassName = cn(
+    buttonRangeClassName,
+    "day-range-end rounded-e-md",
+    props.rangeEndClassName
+  )
+  const _rangeMiddleClassName = cn(
+    "bg-accent !text-foreground [&>button]:bg-transparent [&>button]:!text-foreground [&>button]:hover:bg-transparent [&>button]:hover:!text-foreground",
+    props.rangeMiddleClassName
+  )
+  const _selectedClassName = cn(
+    "[&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground",
+    props.selectedClassName
+  )
+  const _todayClassName = cn(
+    "[&>button]:bg-accent [&>button]:text-accent-foreground",
+    props.todayClassName
+  )
+  const _outsideClassName = cn(
+    "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
+    props.outsideClassName
+  )
+  const _disabledClassName = cn(
+    "text-muted-foreground opacity-50",
+    props.disabledClassName
+  )
+  const _hiddenClassName = cn("invisible flex-1", props.hiddenClassName)
+
+  return (
+    <DayPicker
+      showOutsideDays={showOutsideDays}
+      className={cn("p-3", className)}
+      style={{
+        width: 248.8 * (columnsDisplayed ?? 1) + "px",
+      }}
+      classNames={{
+        months: _monthsClassName,
+        month_caption: _monthCaptionClassName,
+        weekdays: _weekdaysClassName,
+        weekday: _weekdayClassName,
+        month: _monthClassName,
+        caption: _captionClassName,
+        caption_label: _captionLabelClassName,
+        button_next: _buttonNextClassName,
+        button_previous: _buttonPreviousClassName,
+        nav: _navClassName,
+        month_grid: _monthGridClassName,
+        week: _weekClassName,
+        day: _dayClassName,
+        day_button: _dayButtonClassName,
+        range_start: _rangeStartClassName,
+        range_middle: _rangeMiddleClassName,
+        range_end: _rangeEndClassName,
+        selected: _selectedClassName,
+        today: _todayClassName,
+        outside: _outsideClassName,
+        disabled: _disabledClassName,
+        hidden: _hiddenClassName,
+      }}
+      components={{
+        Chevron: ({ orientation }) => {
+          const Icon = orientation === "left" ? ChevronLeft : ChevronRight
+          return <Icon className="h-4 w-4" />
+        },
+        Nav: ({ className }) => (
+          <Nav
+            className={className}
+            displayYears={displayYears}
+            navView={navView}
+            setDisplayYears={setDisplayYears}
+            startMonth={startMonth}
+            endMonth={endMonth}
+            onPrevClick={onPrevClick}
+          />
+        ),
+        CaptionLabel: (props) => (
+          <CaptionLabel
+            showYearSwitcher={showYearSwitcher}
+            navView={navView}
+            setNavView={setNavView}
+            displayYears={displayYears}
+            {...props}
+          />
+        ),
+        MonthGrid: ({ className, children, ...props }) => (
+          <MonthGrid
+            children={children}
+            className={className}
+            displayYears={displayYears}
+            startMonth={startMonth}
+            endMonth={endMonth}
+            navView={navView}
+            setNavView={setNavView}
             {...props}
-        />
-    );
+          />
+        ),
+      }}
+      numberOfMonths={columnsDisplayed}
+      {...props}
+    />
+  )
+}
+Calendar.displayName = "Calendar"
+
+function Nav({
+  className,
+  navView,
+  startMonth,
+  endMonth,
+  displayYears,
+  setDisplayYears,
+  onPrevClick,
+  onNextClick,
+}: {
+  className?: string
+  navView: NavView
+  startMonth?: Date
+  endMonth?: Date
+  displayYears: { from: number; to: number }
+  setDisplayYears: React.Dispatch<
+    React.SetStateAction<{ from: number; to: number }>
+  >
+  onPrevClick?: (date: Date) => void
+  onNextClick?: (date: Date) => void
+}) {
+  const { nextMonth, previousMonth, goToMonth } = useDayPicker()
+
+  const isPreviousDisabled = (() => {
+    if (navView === "years") {
+      return (
+        (startMonth &&
+          differenceInCalendarDays(
+            new Date(displayYears.from - 1, 0, 1),
+            startMonth
+          ) < 0) ||
+        (endMonth &&
+          differenceInCalendarDays(
+            new Date(displayYears.from - 1, 0, 1),
+            endMonth
+          ) > 0)
+      )
+    }
+    return !previousMonth
+  })()
+
+  const isNextDisabled = (() => {
+    if (navView === "years") {
+      return (
+        (startMonth &&
+          differenceInCalendarDays(
+            new Date(displayYears.to + 1, 0, 1),
+            startMonth
+          ) < 0) ||
+        (endMonth &&
+          differenceInCalendarDays(
+            new Date(displayYears.to + 1, 0, 1),
+            endMonth
+          ) > 0)
+      )
+    }
+    return !nextMonth
+  })()
+
+  const handlePreviousClick = React.useCallback(() => {
+    if (!previousMonth) return
+    if (navView === "years") {
+      setDisplayYears((prev) => ({
+        from: prev.from - (prev.to - prev.from + 1),
+        to: prev.to - (prev.to - prev.from + 1),
+      }))
+      onPrevClick?.(
+        new Date(
+          displayYears.from - (displayYears.to - displayYears.from),
+          0,
+          1
+        )
+      )
+      return
+    }
+    goToMonth(previousMonth)
+    onPrevClick?.(previousMonth)
+  }, [previousMonth, goToMonth])
+
+  const handleNextClick = React.useCallback(() => {
+    if (!nextMonth) return
+    if (navView === "years") {
+      setDisplayYears((prev) => ({
+        from: prev.from + (prev.to - prev.from + 1),
+        to: prev.to + (prev.to - prev.from + 1),
+      }))
+      onNextClick?.(
+        new Date(
+          displayYears.from + (displayYears.to - displayYears.from),
+          0,
+          1
+        )
+      )
+      return
+    }
+    goToMonth(nextMonth)
+    onNextClick?.(nextMonth)
+  }, [goToMonth, nextMonth])
+  return (
+    <nav className={cn("flex items-center", className)}>
+      <Button
+        variant="outline"
+        className="absolute left-0 h-7 w-7 bg-transparent p-0 opacity-80 hover:opacity-100"
+        type="button"
+        tabIndex={isPreviousDisabled ? undefined : -1}
+        disabled={isPreviousDisabled}
+        aria-label={
+          navView === "years"
+            ? `Go to the previous ${
+                displayYears.to - displayYears.from + 1
+              } years`
+            : labelPrevious(previousMonth)
+        }
+        onClick={handlePreviousClick}
+      >
+        <ChevronLeft className="h-4 w-4" />
+      </Button>
+
+      <Button
+        variant="outline"
+        className="absolute right-0 h-7 w-7 bg-transparent p-0 opacity-80 hover:opacity-100"
+        type="button"
+        tabIndex={isNextDisabled ? undefined : -1}
+        disabled={isNextDisabled}
+        aria-label={
+          navView === "years"
+            ? `Go to the next ${displayYears.to - displayYears.from + 1} years`
+            : labelNext(nextMonth)
+        }
+        onClick={handleNextClick}
+      >
+        <ChevronRight className="h-4 w-4" />
+      </Button>
+    </nav>
+  )
+}
+
+function CaptionLabel({
+  children,
+  showYearSwitcher,
+  navView,
+  setNavView,
+  displayYears,
+  ...props
+}: {
+  showYearSwitcher?: boolean
+  navView: NavView
+  setNavView: React.Dispatch<React.SetStateAction<NavView>>
+  displayYears: { from: number; to: number }
+} & React.HTMLAttributes<HTMLSpanElement>) {
+  if (!showYearSwitcher) return <span {...props}>{children}</span>
+  return (
+    <Button
+      className="h-7 w-full truncate text-sm font-medium"
+      variant="ghost"
+      size="sm"
+      onClick={() => setNavView((prev) => (prev === "days" ? "years" : "days"))}
+    >
+      {navView === "days"
+        ? children
+        : displayYears.from + " - " + displayYears.to}
+    </Button>
+  )
+}
+
+function MonthGrid({
+  className,
+  children,
+  displayYears,
+  startMonth,
+  endMonth,
+  navView,
+  setNavView,
+  ...props
+}: {
+  className?: string
+  children: React.ReactNode
+  displayYears: { from: number; to: number }
+  startMonth?: Date
+  endMonth?: Date
+  navView: NavView
+  setNavView: React.Dispatch<React.SetStateAction<NavView>>
+} & React.TableHTMLAttributes<HTMLTableElement>) {
+  if (navView === "years") {
+    return (
+      <YearGrid
+        displayYears={displayYears}
+        startMonth={startMonth}
+        endMonth={endMonth}
+        setNavView={setNavView}
+        navView={navView}
+        className={className}
+        {...props}
+      />
+    )
+  }
+  return (
+    <table className={className} {...props}>
+      {children}
+    </table>
+  )
+}
+
+function YearGrid({
+  className,
+  displayYears,
+  startMonth,
+  endMonth,
+  setNavView,
+  navView,
+  ...props
+}: {
+  className?: string
+  displayYears: { from: number; to: number }
+  startMonth?: Date
+  endMonth?: Date
+  setNavView: React.Dispatch<React.SetStateAction<NavView>>
+  navView: NavView
+} & React.HTMLAttributes<HTMLDivElement>) {
+  const { goToMonth, selected } = useDayPicker()
+
+  return (
+    <div className={cn("grid grid-cols-4 gap-y-2", className)} {...props}>
+      {Array.from(
+        { length: displayYears.to - displayYears.from + 1 },
+        (_, i) => {
+          const isBefore =
+            differenceInCalendarDays(
+              new Date(displayYears.from + i, 11, 31),
+              startMonth!
+            ) < 0
+
+          const isAfter =
+            differenceInCalendarDays(
+              new Date(displayYears.from + i, 0, 0),
+              endMonth!
+            ) > 0
+
+          const isDisabled = isBefore || isAfter
+          return (
+            <Button
+              key={i}
+              className={cn(
+                "h-7 w-full text-sm font-normal text-foreground",
+                displayYears.from + i === new Date().getFullYear() &&
+                  "bg-accent font-medium text-accent-foreground"
+              )}
+              variant="ghost"
+              onClick={() => {
+                setNavView("days")
+                goToMonth(
+                  new Date(
+                    displayYears.from + i,
+                    (selected as Date | undefined)?.getMonth() ?? 0
+                  )
+                )
+              }}
+              disabled={navView === "years" ? isDisabled : undefined}
+            >
+              {displayYears.from + i}
+            </Button>
+          )
+        }
+      )}
+    </div>
+  )
 }
 
-export { Calendar };
+export { Calendar }

+ 113 - 3
packages/dashboard/src/lib/framework/document-introspection/get-document-structure.spec.ts

@@ -1,7 +1,7 @@
 import { graphql } from 'gql.tada';
 import { describe, expect, it, vi } from 'vitest';
 
-import { getListQueryFields } from './get-document-structure.js';
+import { getListQueryFields, getOperationVariablesFields } from './get-document-structure.js';
 
 vi.mock('virtual:admin-api-schema', () => {
     return {
@@ -12,7 +12,10 @@ vi.mock('virtual:admin-api-schema', () => {
                     product: ['Product', false, false, false],
                     collection: ['Collection', false, false, false],
                 },
-                Mutation: {},
+                Mutation: {
+                    updateProduct: ['Product', false, false, false],
+                    adjustDraftOrderLine: ['Order', false, false, false],
+                },
 
                 Collection: {
                     id: ['ID', false, false, false],
@@ -126,8 +129,25 @@ vi.mock('virtual:admin-api-schema', () => {
                     languageCode: ['LanguageCode', false, false, false],
                     name: ['String', false, false, false],
                 },
+                Order: {
+                    id: ['ID', false, false, false],
+                    lines: ['OrderLine', false, true, false],
+                },
+                OrderLine: {
+                    id: ['ID', false, false, false],
+                    quantity: ['Int', false, false, false],
+                },
+            },
+            inputs: {
+                UpdateProductInput: {
+                    id: ['ID', false, false, false],
+                    name: ['String', false, false, false],
+                },
+                AdjustDraftOrderLineInput: {
+                    orderLineId: ['ID', false, false, false],
+                    quantity: ['Int', false, false, false],
+                },
             },
-            inputs: {},
             scalars: ['ID', 'String', 'Int', 'Boolean', 'Float', 'JSON', 'DateTime', 'Upload', 'Money'],
             enums: {},
         },
@@ -308,3 +328,93 @@ describe('getListQueryFields', () => {
         ]);
     });
 });
+
+describe('getOperationVariablesFields', () => {
+    it('should extract fields from a simple mutation', () => {
+        const doc = graphql(`
+            mutation UpdateProduct($input: UpdateProductInput!) {
+                updateProduct(input: $input) {
+                    ...ProductDetail
+                }
+            }
+
+            fragment ProductDetail on Product {
+                id
+                name
+            }
+        `);
+
+        const fields = getOperationVariablesFields(doc, 'input');
+        expect(fields).toEqual([
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'id',
+                nullable: false,
+                type: 'ID',
+                typeInfo: undefined,
+            },
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'name',
+                nullable: false,
+                type: 'String',
+                typeInfo: undefined,
+            },
+        ]);
+    });
+
+    it('should handle a mutation with a nested input', () => {
+        const doc = graphql(`
+            mutation AdjustDraftOrderLine($orderId: ID!, $input: AdjustDraftOrderLineInput!) {
+                adjustDraftOrderLine(orderId: $orderId, input: $input) {
+                    id
+                }
+            }
+        `);
+
+        const fields = getOperationVariablesFields(doc, undefined);
+        expect(fields).toEqual([
+            {
+                name: 'orderId',
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                nullable: false,
+                type: 'ID',
+                typeInfo: undefined,
+            },
+            {
+                name: 'input',
+                isPaginatedList: false,
+                isScalar: false,
+                list: false,
+                nullable: true,
+                type: 'AdjustDraftOrderLineInput',
+                typeInfo: [
+                    {
+                        name: 'orderLineId',
+                        isPaginatedList: false,
+                        isScalar: true,
+                        list: false,
+                        nullable: false,
+                        type: 'ID',
+                        typeInfo: undefined,
+                    },
+                    {
+                        name: 'quantity',
+                        isPaginatedList: false,
+                        isScalar: true,
+                        list: false,
+                        nullable: false,
+                        type: 'Int',
+                        typeInfo: undefined,
+                    },
+                ],
+            },
+        ]);
+    });
+});

+ 70 - 11
packages/dashboard/src/lib/framework/document-introspection/get-document-structure.ts

@@ -1,3 +1,5 @@
+import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
+import { VariablesOf } from 'gql.tada';
 import {
     DocumentNode,
     OperationDefinitionNode,
@@ -146,7 +148,10 @@ function findNestedPaginatedLists(
  *
  * The operation variables fields are the fields of the `UpdateProductInput` type.
  */
-export function getOperationVariablesFields(documentNode: DocumentNode): FieldInfo[] {
+export function getOperationVariablesFields<T extends TypedDocumentNode<any, any>>(
+    documentNode: T,
+    varName?: keyof VariablesOf<T>,
+): FieldInfo[] {
     const fields: FieldInfo[] = [];
 
     const operationDefinition = documentNode.definitions.find(
@@ -154,11 +159,31 @@ export function getOperationVariablesFields(documentNode: DocumentNode): FieldIn
     );
 
     if (operationDefinition?.variableDefinitions) {
-        operationDefinition.variableDefinitions.forEach(variable => {
-            const unwrappedType = unwrapVariableDefinitionType(variable.type);
-            const inputName = unwrappedType.name.value;
-            const inputFields = getInputTypeInfo(inputName);
-            fields.push(...inputFields);
+        const variableDefinitions = varName
+            ? operationDefinition.variableDefinitions.filter(
+                  variable => variable.variable.name.value === varName,
+              )
+            : operationDefinition.variableDefinitions;
+        variableDefinitions.forEach(variableDef => {
+            const unwrappedType = unwrapVariableDefinitionType(variableDef.type);
+            const isScalar = isScalarType(unwrappedType.name.value);
+            const fieldName = variableDef.variable.name.value;
+            const typeName = unwrappedType.name.value;
+            const inputTypeInfo = isScalar
+                ? {
+                      name: fieldName,
+                      type: typeName,
+                      nullable: false,
+                      list: false,
+                      isScalar: true,
+                      isPaginatedList: false,
+                  }
+                : getInputTypeInfo(fieldName, typeName);
+            if (varName && inputTypeInfo?.name === varName) {
+                fields.push(...(inputTypeInfo.typeInfo ?? []));
+            } else {
+                fields.push(inputTypeInfo);
+            }
         });
     }
 
@@ -331,7 +356,9 @@ export function getOperationTypeInfo(
     if (definitionNode.kind === 'OperationDefinition') {
         const firstSelection = definitionNode?.selectionSet.selections[0];
         if (firstSelection?.kind === 'Field') {
-            return getQueryInfo(firstSelection.name.value);
+            return definitionNode.operation === 'query'
+                ? getQueryInfo(firstSelection.name.value)
+                : getMutationInfo(firstSelection.name.value);
         }
     }
     if (definitionNode.kind === 'Field' && parentTypeName) {
@@ -349,7 +376,7 @@ export function getTypeFieldInfo(typeName: string): FieldInfo[] {
             }
             return fieldInfo;
         })
-        .filter(x => x != null) as FieldInfo[];
+        .filter(x => x != null);
 }
 
 function getQueryInfo(name: string): FieldInfo {
@@ -364,8 +391,40 @@ function getQueryInfo(name: string): FieldInfo {
     };
 }
 
-function getInputTypeInfo(name: string): FieldInfo[] {
-    return Object.entries(schemaInfo.inputs[name]).map(([fieldName, fieldInfo]: [string, any]) => {
+function getMutationInfo(name: string): FieldInfo {
+    const fieldInfo = schemaInfo.types.Mutation[name];
+    return {
+        name,
+        type: fieldInfo[0],
+        nullable: fieldInfo[1],
+        list: fieldInfo[2],
+        isPaginatedList: fieldInfo[3],
+        isScalar: schemaInfo.scalars.includes(fieldInfo[0]),
+    };
+}
+
+function getInputTypeInfo(name: string, type: string): FieldInfo {
+    const fieldInfo = schemaInfo.inputs[type];
+    if (!fieldInfo) {
+        throw new Error(`Input type ${type} not found`);
+    }
+    return {
+        name,
+        type,
+        nullable: true,
+        list: false,
+        isPaginatedList: false,
+        isScalar: false,
+        typeInfo: getInputTypeFields(type),
+    };
+}
+
+function getInputTypeFields(name: string): FieldInfo[] {
+    const inputType = schemaInfo.inputs[name];
+    if (!inputType) {
+        throw new Error(`Input type ${name} not found`);
+    }
+    return Object.entries(inputType).map(([fieldName, fieldInfo]: [string, any]) => {
         const type = fieldInfo[0];
         const isScalar = isScalarType(type);
         const isEnum = isEnumType(type);
@@ -376,7 +435,7 @@ function getInputTypeInfo(name: string): FieldInfo[] {
             list: fieldInfo[2],
             isPaginatedList: fieldInfo[3],
             isScalar,
-            typeInfo: !isScalar && !isEnum ? getInputTypeInfo(type) : undefined,
+            typeInfo: !isScalar && !isEnum ? getInputTypeFields(type) : undefined,
         };
     });
 }

+ 8 - 7
packages/dashboard/src/lib/framework/form-engine/use-generated-form.tsx

@@ -13,13 +13,14 @@ import { useForm } from 'react-hook-form';
 
 export interface GeneratedFormOptions<
     T extends TypedDocumentNode<any, any>,
-    VarName extends keyof VariablesOf<T> = 'input',
+    VarName extends (keyof VariablesOf<T>) | undefined = 'input',
     E extends Record<string, any> = Record<string, any>,
 > {
     document?: T;
+    varName?: VarName;
     entity: E | null | undefined;
-    setValues: (entity: NonNullable<E>) => VariablesOf<T>[VarName];
-    onSubmit?: (values: VariablesOf<T>[VarName]) => void;
+    setValues: (entity: NonNullable<E>) => VarName extends keyof VariablesOf<T> ? VariablesOf<T>[VarName] : VariablesOf<T>;
+    onSubmit?: (values: VarName extends keyof VariablesOf<T> ? VariablesOf<T>[VarName] : VariablesOf<T>) => void;
 }
 
 /**
@@ -31,13 +32,13 @@ export interface GeneratedFormOptions<
  */
 export function useGeneratedForm<
     T extends TypedDocumentNode<any, any>,
-    VarName extends keyof VariablesOf<T> = 'input',
+    VarName extends keyof VariablesOf<T> | undefined,
     E extends Record<string, any> = Record<string, any>,
 >(options: GeneratedFormOptions<T, VarName, E>) {
-    const { document, entity, setValues, onSubmit } = options;
+    const { document, entity, setValues, onSubmit, varName } = options;
     const { activeChannel } = useChannel();
     const availableLanguages = useServerConfig()?.availableLanguages || [];
-    const updateFields = document ? getOperationVariablesFields(document) : [];
+    const updateFields = document ? getOperationVariablesFields(document, varName) : [];
     const schema = createFormSchemaFromFields(updateFields);
     const defaultValues = getDefaultValuesFromFields(updateFields, activeChannel?.defaultLanguageCode);
     const processedEntity = ensureTranslationsForAllLanguages(entity, availableLanguages);
@@ -58,7 +59,7 @@ export function useGeneratedForm<
     };
     if (onSubmit) {
         submitHandler = (event: FormEvent) => {
-            form.handleSubmit(onSubmit)(event);
+            form.handleSubmit(onSubmit as any)(event);
         };
     }
 

+ 4 - 0
packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx

@@ -5,10 +5,13 @@ import { Form } from '@/components/ui/form.js';
 import { useCustomFieldConfig } from '@/hooks/use-custom-field-config.js';
 import { usePage } from '@/hooks/use-page.js';
 import { cn } from '@/lib/utils.js';
+import { NavigationConfirmation } from '@/components/shared/navigation-confirmation.js';
 import { useMediaQuery } from '@uidotdev/usehooks';
 import React, { ComponentProps, createContext } from 'react';
 import { Control, UseFormReturn } from 'react-hook-form';
+
 import { DashboardActionBarItem } from '../extension-api/extension-api-types.js';
+
 import { getDashboardActionBarItems, getDashboardPageBlocks } from './layout-extensions.js';
 import { LocationWrapper } from './location-wrapper.js';
 
@@ -42,6 +45,7 @@ export function Page({ children, pageId, entity, form, submitHandler, ...props }
 
     const pageContentWithOptionalForm = form ? (
         <Form {...form}>
+            <NavigationConfirmation form={form} />
             <form onSubmit={submitHandler} className="space-y-4">
                 {pageHeader}
                 {pageContent}

+ 23 - 4
packages/dashboard/src/lib/framework/page/list-page.tsx

@@ -11,7 +11,9 @@ 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 { useUserSettings } from '@/hooks/use-user-settings.js';
 import { ResultOf } from 'gql.tada';
+
 import { addCustomFields } from '../document-introspection/add-custom-fields.js';
 import {
     FullWidthPageBlock,
@@ -81,12 +83,18 @@ export function ListPage<
     const route = typeof routeOrFn === 'function' ? routeOrFn() : routeOrFn;
     const routeSearch = route.useSearch();
     const navigate = useNavigate<AnyRouter>({ from: route.fullPath });
+    const { setTableSettings, settings } = useUserSettings();
+    const tableSettings = pageId ? settings.tableSettings?.[pageId] : undefined;
 
     const pagination = {
         page: routeSearch.page ? parseInt(routeSearch.page) : 1,
-        itemsPerPage: routeSearch.perPage ? parseInt(routeSearch.perPage) : 10,
+        itemsPerPage: routeSearch.perPage ? parseInt(routeSearch.perPage) : tableSettings?.pageSize ?? 10,
     };
 
+    const columnVisibility = pageId ? tableSettings?.columnVisibility : defaultVisibility;
+    const columnOrder = pageId ? tableSettings?.columnOrder : defaultColumnOrder;
+    const columnFilters = pageId ? tableSettings?.columnFilters : routeSearch.filters;
+
     const sorting: SortingState = (routeSearch.sort ?? '')
         .split(',')
         .filter((s: string) => s.length)
@@ -138,21 +146,32 @@ export function ListPage<
                         transformVariables={transformVariables}
                         customizeColumns={customizeColumns as any}
                         additionalColumns={additionalColumns as any}
-                        defaultColumnOrder={defaultColumnOrder}
-                        defaultVisibility={defaultVisibility}
+                        defaultColumnOrder={columnOrder as any}
+                        defaultVisibility={columnVisibility as any}
                         onSearchTermChange={onSearchTermChange}
                         page={pagination.page}
                         itemsPerPage={pagination.itemsPerPage}
                         sorting={sorting}
-                        columnFilters={routeSearch.filters}
+                        columnFilters={columnFilters}
                         onPageChange={(table, page, perPage) => {
                             persistListStateToUrl(table, { page, perPage });
+                            if (pageId) {
+                                setTableSettings(pageId, 'pageSize', perPage);
+                            }
                         }}
                         onSortChange={(table, sorting) => {
                             persistListStateToUrl(table, { sort: sorting });
                         }}
                         onFilterChange={(table, filters) => {
                             persistListStateToUrl(table, { filters });
+                            if (pageId) {
+                                setTableSettings(pageId, 'columnFilters', filters);
+                            }
+                        }}
+                        onColumnVisibilityChange={(table, columnVisibility) => {
+                            if (pageId) {
+                                setTableSettings(pageId, 'columnVisibility', columnVisibility);
+                            }
                         }}
                         facetedFilters={facetedFilters}
                         rowActions={rowActions}

+ 1 - 0
packages/dashboard/src/lib/framework/page/use-detail-page.ts

@@ -195,6 +195,7 @@ export function useDetailPage<
     const document = isNew ? (createDocument ?? updateDocument) : updateDocument;
     const { form, submitHandler } = useGeneratedForm({
         document,
+        varName: 'input',
         entity,
         setValues: setValuesForUpdate,
         onSubmit(values: any) {

+ 8 - 0
packages/dashboard/src/lib/graphql/fragments.tsx

@@ -51,4 +51,12 @@ export const configurableOperationDefFragment = graphql(`
     }
 `);
 
+export const errorResultFragment = graphql(`
+    fragment ErrorResult on ErrorResult {
+        errorCode
+        message
+    }
+`);
+
+
 export type ConfigurableOperationDefFragment = ResultOf<typeof configurableOperationDefFragment>;

+ 5 - 5
packages/dashboard/src/lib/index.ts

@@ -28,10 +28,10 @@ export * from './components/layout/nav-user.js';
 export * from './components/login/login-form.js';
 export * from './components/shared/alerts.js';
 export * from './components/shared/animated-number.js';
-export * from './components/shared/asset-gallery.js';
-export * from './components/shared/asset-picker-dialog.js';
-export * from './components/shared/asset-preview-dialog.js';
-export * from './components/shared/asset-preview.js';
+export * from './components/shared/asset/asset-gallery.js';
+export * from './components/shared/asset/asset-picker-dialog.js';
+export * from './components/shared/asset/asset-preview-dialog.js';
+export * from './components/shared/asset/asset-preview.js';
 export * from './components/shared/assigned-facet-values.js';
 export * from './components/shared/channel-code-label.js';
 export * from './components/shared/channel-selector.js';
@@ -50,7 +50,7 @@ export * from './components/shared/entity-assets.js';
 export * from './components/shared/error-page.js';
 export * from './components/shared/facet-value-chip.js';
 export * from './components/shared/facet-value-selector.js';
-export * from './components/shared/focal-point-control.js';
+export * from './components/shared/asset/focal-point-control.js';
 export * from './components/shared/form-field-wrapper.js';
 export * from './components/shared/history-timeline/history-entry.js';
 export * from './components/shared/history-timeline/history-note-checkbox.js';

+ 12 - 9
packages/dashboard/src/lib/providers/auth.tsx

@@ -69,17 +69,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     const onLogoutSuccessFn = React.useRef<() => void>(() => {});
     const isAuthenticated = status === 'authenticated';
 
-    const { data: currentUserData, isLoading } = useQuery({
-        queryKey: ['currentUser'],
+    const { data: currentUserData, isLoading , error: currentUserError} = useQuery({
+        queryKey: ['currentUser', isAuthenticated],
         queryFn: () => api.query(CurrentUserQuery),
         retry: false,
+        staleTime: 1000,
     });
 
+    const currentUser = currentUserError ? undefined : currentUserData;
+
     React.useEffect(() => {
-        if (!settings.activeChannelId && currentUserData?.me?.channels?.length) {
-            setActiveChannelId(currentUserData.me.channels[0].id);
+        if (!settings.activeChannelId && currentUser?.me?.channels?.length) {
+            setActiveChannelId(currentUser.me.channels[0].id);
         }
-    }, [settings.activeChannelId, currentUserData?.me?.channels]);
+    }, [settings.activeChannelId, currentUser?.me?.channels]);
 
     const loginMutationFn = api.mutate(LoginMutation);
     const loginMutation = useMutation({
@@ -123,7 +126,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
 
     React.useEffect(() => {
         if (!isLoading) {
-            if (currentUserData?.me?.id) {
+            if (currentUser?.me?.id) {
                 setStatus('authenticated');
             } else {
                 setStatus('unauthenticated');
@@ -131,7 +134,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
         } else {
             setStatus('verifying');
         }
-    }, [isLoading, currentUserData]);
+    }, [isLoading, currentUser]);
 
     return (
         <AuthContext.Provider
@@ -139,8 +142,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
                 isAuthenticated,
                 authenticationError,
                 status,
-                user: currentUserData?.activeAdministrator,
-                channels: currentUserData?.me?.channels,
+                user: currentUser?.activeAdministrator,
+                channels: currentUser?.me?.channels,
                 login,
                 logout,
             }}

+ 1 - 0
packages/dashboard/src/lib/providers/channel-provider.tsx

@@ -73,6 +73,7 @@ export function ChannelProvider({ children }: { children: React.ReactNode }) {
     const { data: channelsData, isLoading: isChannelsLoading } = useQuery({
         queryKey: ['channels'],
         queryFn: () => api.query(ChannelsQuery),
+        retry: false,
     });
 
     // Set the selected channel and update localStorage

+ 7 - 1
packages/dashboard/src/lib/providers/server-config.tsx

@@ -1,5 +1,6 @@
 import { api } from '@/graphql/api.js';
 import { graphql } from '@/graphql/graphql.js';
+import { useAuth } from '@/hooks/use-auth.js';
 import { useQuery } from '@tanstack/react-query';
 import { ResultOf } from 'gql.tada';
 import React from 'react';
@@ -260,9 +261,14 @@ export interface ServerConfig {
 
 // create a provider for the global settings
 export const ServerConfigProvider = ({ children }: { children: React.ReactNode }) => {
+    const { user } = useAuth();
+    const queryKey = ['getServerConfig', user?.id];
     const { data } = useQuery({
-        queryKey: ['getServerConfig'],
+        queryKey,
         queryFn: () => api.query(getServerConfigDocument),
+        retry: false,
+        enabled: !!user?.id,
+        staleTime: 1000,
     });
     const value: ServerConfig = {
         availableLanguages: data?.globalSettings.availableLanguages ?? [],

+ 24 - 0
packages/dashboard/src/lib/providers/user-settings.tsx

@@ -1,5 +1,13 @@
 import React, { createContext, useState, useEffect } from 'react';
 import { Theme } from './theme-provider.js';
+import { ColumnFiltersState } from '@tanstack/react-table';
+
+export interface TableSettings {
+    columnVisibility?: Record<string, boolean>;
+    columnOrder?: string[];
+    columnFilters?: ColumnFiltersState;
+    pageSize?: number;
+}
 
 export interface UserSettings {
     displayLanguage: string;
@@ -11,6 +19,7 @@ export interface UserSettings {
     activeChannelId: string;
     devMode: boolean;
     hasSeenOnboarding: boolean;
+    tableSettings?: Record<string, TableSettings>;
 }
 
 const defaultSettings: UserSettings = {
@@ -23,6 +32,7 @@ const defaultSettings: UserSettings = {
     activeChannelId: '',
     devMode: false,
     hasSeenOnboarding: false,
+    tableSettings: {},
 };
 
 export interface UserSettingsContextType {
@@ -36,6 +46,11 @@ export interface UserSettingsContextType {
     setActiveChannelId: (channelId: string) => void;
     setDevMode: (devMode: boolean) => void;
     setHasSeenOnboarding: (hasSeen: boolean) => void;
+    setTableSettings: <K extends keyof TableSettings>(
+        tableId: string,
+        key: K,
+        value: TableSettings[K],
+    ) => void;
 }
 
 export const UserSettingsContext = createContext<UserSettingsContextType | undefined>(undefined);
@@ -83,6 +98,15 @@ export const UserSettingsProvider: React.FC<React.PropsWithChildren<{}>> = ({ ch
         setActiveChannelId: channelId => updateSetting('activeChannelId', channelId),
         setDevMode: devMode => updateSetting('devMode', devMode),
         setHasSeenOnboarding: hasSeen => updateSetting('hasSeenOnboarding', hasSeen),
+        setTableSettings: (tableId, key, value) => {
+            setSettings(prev => ({
+                ...prev,
+                tableSettings: {
+                    ...prev.tableSettings,
+                    [tableId]: { ...(prev.tableSettings?.[tableId] || {}), [key]: value },
+                },
+            }));
+        },
     };
 
     return <UserSettingsContext.Provider value={contextValue}>{children}</UserSettingsContext.Provider>;

+ 7 - 1
packages/dashboard/vite.config.mts

@@ -21,6 +21,12 @@ export default ({ mode }: { mode: string }) => {
         isLocalDev: process.env.IS_LOCAL_DEV,
     });
 
+    const vendureConfigPath = process.env.VITEST ?
+        // This should always be used for running the tests
+        './sample-vendure-config.ts'
+        // This one might be changed to '../dev-server/dev-config.ts' to test ui extensions
+        : './sample-vendure-config.ts';
+
     return defineConfig({
         test: {
             globals: true,
@@ -28,7 +34,7 @@ export default ({ mode }: { mode: string }) => {
         },
         plugins: [
             vendureDashboardPlugin({
-                vendureConfigPath: pathToFileURL('./sample-vendure-config.ts'),
+                vendureConfigPath: pathToFileURL(vendureConfigPath),
                 adminUiConfig: { apiHost: adminApiHost, apiPort: adminApiPort },
                 // gqlTadaOutputPath: path.resolve(__dirname, './graphql/'),
                 tempCompilationDir: path.resolve(__dirname, './.temp'),

+ 4 - 2
packages/dashboard/vite/utils/config-loader.ts

@@ -389,7 +389,8 @@ function reportDiagnostics(program: ts.Program, emitResult: ts.EmitResult, logge
             const { line, character } = ts.getLineAndCharacterOfPosition(diagnostic.file, diagnostic.start);
             const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
             const logFn = diagnostic.category === ts.DiagnosticCategory.Error ? logger.warn : logger.info;
-            logFn(
+            // eslint-disable-next-line no-console
+            console.log(
                 `TS${diagnostic.code} ${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`,
             );
             if (diagnostic.category === ts.DiagnosticCategory.Error) {
@@ -398,7 +399,8 @@ function reportDiagnostics(program: ts.Program, emitResult: ts.EmitResult, logge
         } else {
             const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
             const logFn = diagnostic.category === ts.DiagnosticCategory.Error ? logger.warn : logger.info;
-            logFn(`TS${diagnostic.code}: ${message}`);
+            // eslint-disable-next-line no-console
+            console.log(`TS${diagnostic.code}: ${message}`);
             if (diagnostic.category === ts.DiagnosticCategory.Error) {
                 hasEmitErrors = true;
             }

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott