Sfoglia il codice sorgente

Merge branch 'master' into minor

Michael Bromley 4 mesi fa
parent
commit
9f4e3fe1b4
75 ha cambiato i file con 4144 aggiunte e 1251 eliminazioni
  1. 8 8
      docs/docs/reference/dashboard/components/asset-gallery.md
  2. 4 3
      docs/docs/reference/dashboard/components/detail-page-button.md
  3. 10 2
      docs/docs/reference/dashboard/extensions-api/define-dashboard-extension.md
  4. 250 0
      docs/docs/reference/dashboard/extensions-api/history-entries.md
  5. 5 2
      docs/docs/reference/dashboard/list-views/list-page.md
  6. 2 2
      docs/docs/reference/dashboard/page-layout/index.md
  7. 3 3
      docs/docs/reference/dashboard/page-layout/page-action-bar.md
  8. 4 4
      docs/docs/reference/dashboard/page-layout/page-block.md
  9. 1 1
      docs/docs/reference/dashboard/page-layout/page-title.md
  10. 2 2
      docs/docs/reference/dashboard/page-layout/page.md
  11. 1 1
      packages/core/src/service/services/channel.service.ts
  12. 11 12
      packages/dashboard/index.html
  13. 263 0
      packages/dashboard/src/app/common/use-page-title.test.ts
  14. 86 0
      packages/dashboard/src/app/common/use-page-title.ts
  15. 4 4
      packages/dashboard/src/app/routes/__root.tsx
  16. 2 0
      packages/dashboard/src/app/routes/_authenticated/_countries/countries.graphql.ts
  17. 2 2
      packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/customer-history-container.tsx
  18. 5 0
      packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/customer-history-types.ts
  19. 124 0
      packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/customer-history-utils.tsx
  20. 91 59
      packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/customer-history.tsx
  21. 176 0
      packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/default-customer-history-components.tsx
  22. 4 2
      packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/index.ts
  23. 2 0
      packages/dashboard/src/app/routes/_authenticated/_customers/customers.graphql.ts
  24. 302 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-detail-shared.tsx
  25. 98 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/default-order-history-components.tsx
  26. 9 7
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/order-history-container.tsx
  27. 5 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/order-history-types.ts
  28. 173 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/order-history-utils.tsx
  29. 64 408
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/order-history.tsx
  30. 16 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-table-totals.tsx
  31. 61 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/seller-orders-card.tsx
  32. 17 10
      packages/dashboard/src/app/routes/_authenticated/_orders/components/use-transition-order-to-state.tsx
  33. 35 0
      packages/dashboard/src/app/routes/_authenticated/_orders/orders.graphql.ts
  34. 50 0
      packages/dashboard/src/app/routes/_authenticated/_orders/orders_.$aggregateOrderId_.seller-orders.$sellerOrderId.tsx
  35. 17 290
      packages/dashboard/src/app/routes/_authenticated/_orders/orders_.$id.tsx
  36. 7 39
      packages/dashboard/src/app/routes/_authenticated/_orders/orders_.$id_.modify.tsx
  37. 4 26
      packages/dashboard/src/app/routes/_authenticated/_orders/orders_.draft.$id.tsx
  38. 134 0
      packages/dashboard/src/app/routes/_authenticated/_orders/utils/order-detail-loaders.tsx
  39. 8 0
      packages/dashboard/src/app/routes/_authenticated/_orders/utils/order-utils.ts
  40. 0 120
      packages/dashboard/src/app/routes/_authenticated/_roles/components/permissions-grid.tsx
  41. 251 0
      packages/dashboard/src/app/routes/_authenticated/_roles/components/permissions-table-grid.tsx
  42. 5 3
      packages/dashboard/src/app/routes/_authenticated/_roles/roles_.$id.tsx
  43. 15 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/metadata-badges.tsx
  44. 21 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/price-display.tsx
  45. 87 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/shipping-method-test-result-wrapper.tsx
  46. 255 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-address-form.tsx
  47. 243 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-order-builder.tsx
  48. 0 32
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-method-dialog.tsx
  49. 97 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-methods-result.tsx
  50. 41 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-methods-sheet.tsx
  51. 74 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-methods.tsx
  52. 90 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-single-method-result.tsx
  53. 56 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-single-shipping-method-sheet.tsx
  54. 82 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-single-shipping-method.tsx
  55. 67 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/use-shipping-method-test-state.ts
  56. 27 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods.graphql.ts
  57. 2 2
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods.tsx
  58. 24 4
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods_.$id.tsx
  59. 6 4
      packages/dashboard/src/lib/components/shared/detail-page-button.tsx
  60. 0 188
      packages/dashboard/src/lib/components/shared/history-timeline/history-entry.tsx
  61. 65 0
      packages/dashboard/src/lib/components/shared/history-timeline/history-note-entry.tsx
  62. 141 0
      packages/dashboard/src/lib/components/shared/history-timeline/history-timeline-with-grouping.tsx
  63. 26 0
      packages/dashboard/src/lib/components/shared/history-timeline/use-history-note-editor.ts
  64. 5 0
      packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts
  65. 7 0
      packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts
  66. 24 0
      packages/dashboard/src/lib/framework/extension-api/logic/history-entries.ts
  67. 1 0
      packages/dashboard/src/lib/framework/extension-api/logic/index.ts
  68. 120 0
      packages/dashboard/src/lib/framework/extension-api/types/history-entries.ts
  69. 1 0
      packages/dashboard/src/lib/framework/extension-api/types/index.ts
  70. 11 0
      packages/dashboard/src/lib/framework/history-entry/history-entry-extensions.ts
  71. 129 0
      packages/dashboard/src/lib/framework/history-entry/history-entry.tsx
  72. 96 9
      packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx
  73. 2 0
      packages/dashboard/src/lib/framework/registry/registry-types.ts
  74. 12 1
      packages/dashboard/src/lib/index.ts
  75. 1 1
      packages/dev-server/graphql/graphql-env.d.ts

+ 8 - 8
docs/docs/reference/dashboard/components/asset-gallery.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## AssetGallery
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/components/shared/asset/asset-gallery.tsx" sourceLine="153" packageName="@vendure/dashboard" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/components/shared/asset/asset-gallery.tsx" sourceLine="155" packageName="@vendure/dashboard" />
 
 A component for displaying a gallery of assets.
 
@@ -19,12 +19,12 @@ A component for displaying a gallery of assets.
 
 ```tsx
  <AssetGallery
-     onSelect={handleAssetSelect}
-     multiSelect="manual"
-     initialSelectedAssets={initialSelectedAssets}
-     fixedHeight={false}
-     displayBulkActions={false}
- />
+onSelect={handleAssetSelect}
+multiSelect="manual"
+initialSelectedAssets={initialSelectedAssets}
+fixedHeight={false}
+displayBulkActions={false}
+/>
 ```
 
 ```ts title="Signature"
@@ -40,7 +40,7 @@ Parameters
 
 ## AssetGalleryProps
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/components/shared/asset/asset-gallery.tsx" sourceLine="81" packageName="@vendure/dashboard" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/components/shared/asset/asset-gallery.tsx" sourceLine="83" packageName="@vendure/dashboard" />
 
 Props for the <a href='/reference/dashboard/components/asset-gallery#assetgallery'>AssetGallery</a> component.
 

+ 4 - 3
docs/docs/reference/dashboard/components/detail-page-button.md

@@ -36,17 +36,18 @@ navigate to detail views.
 ```
 
 ```ts title="Signature"
-function DetailPageButton(props: {
+function DetailPageButton(props: Readonly<{
     label: string | React.ReactNode;
     id?: string;
     href?: string;
     disabled?: boolean;
     search?: Record<string, string>;
-}): void
+    className?: string;
+}>): void
 ```
 Parameters
 
 ### props
 
-<MemberInfo kind="parameter" type={`{     label: string | React.ReactNode;     id?: string;     href?: string;     disabled?: boolean;     search?: Record&#60;string, string&#62;; }`} />
+<MemberInfo kind="parameter" type={`Readonly&#60;{     label: string | React.ReactNode;     id?: string;     href?: string;     disabled?: boolean;     search?: Record&#60;string, string&#62;;     className?: string; }&#62;`} />
 

+ 10 - 2
docs/docs/reference/dashboard/extensions-api/define-dashboard-extension.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## defineDashboardExtension
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts" sourceLine="60" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts" sourceLine="62" packageName="@vendure/dashboard" since="3.3.0" />
 
 The main entry point for extensions to the React-based dashboard. Every dashboard extension
 must contain a call to this function, usually in the entry point file that is referenced by
@@ -26,6 +26,7 @@ Every type of customisation of the dashboard can be defined here, including:
 - Data tables
 - Detail forms
 - Login
+- Custom history entries
 
 *Example*
 
@@ -51,7 +52,7 @@ Parameters
 
 ## DashboardExtension
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts" sourceLine="33" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts" sourceLine="34" packageName="@vendure/dashboard" since="3.3.0" />
 
 This is the main interface for defining _all_ extensions to the dashboard.
 
@@ -77,6 +78,7 @@ interface DashboardExtension {
     dataTables?: DashboardDataTableExtensionDefinition[];
     detailForms?: DashboardDetailFormExtensionDefinition[];
     login?: DashboardLoginExtensions;
+    historyEntries?: DashboardHistoryEntryComponent[];
 }
 ```
 
@@ -133,6 +135,12 @@ Allows you to customize the detail form for any page in the dashboard.
 <MemberInfo kind="property" type={`<a href='/reference/dashboard/extensions-api/login#dashboardloginextensions'>DashboardLoginExtensions</a>`}   />
 
 Allows you to customize the login page with custom components.
+### historyEntries
+
+<MemberInfo kind="property" type={`<a href='/reference/dashboard/extensions-api/history-entries#dashboardhistoryentrycomponent'>DashboardHistoryEntryComponent</a>[]`}   />
+
+Allows a custom component to be used to render a history entry item
+in the Order or Customer history lists.
 
 
 </div>

+ 250 - 0
docs/docs/reference/dashboard/extensions-api/history-entries.md

@@ -0,0 +1,250 @@
+---
+title: "HistoryEntries"
+isDefaultIndex: false
+generated: true
+---
+<!-- This file was generated from the Vendure source. Do not modify. Instead, re-run the "docs:build" script -->
+import MemberInfo from '@site/src/components/MemberInfo';
+import GenerationInfo from '@site/src/components/GenerationInfo';
+import MemberDescription from '@site/src/components/MemberDescription';
+
+
+## DashboardHistoryEntryComponent
+
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/types/history-entries.ts" sourceLine="96" packageName="@vendure/dashboard" since="3.4.3" />
+
+A definition of a custom component that will be used to render the given
+type of history entry.
+
+*Example*
+
+```tsx
+import { defineDashboardExtension, HistoryEntry } from '@vendure/dashboard';
+import { IdCard } from 'lucide-react';
+
+defineDashboardExtension({
+    historyEntries: [
+        {
+            type: 'CUSTOMER_TAX_ID_APPROVAL',
+            component: ({ entry, entity }) => {
+                return (
+                    <HistoryEntry
+                        entry={entry}
+                        title={'Tax ID verified'}
+                        timelineIconClassName={'bg-success text-success-foreground'}
+                        timelineIcon={<IdCard />}
+                    >
+                        <div className="text-xs">Approval reference: {entry.data.ref}</div>
+                    </HistoryEntry>
+                );
+            },
+        },
+    ],
+ });
+ ```
+
+```ts title="Signature"
+interface DashboardHistoryEntryComponent {
+    type: string;
+    component: React.ComponentType<{
+        entry: HistoryEntryItem;
+        entity: OrderHistoryOrderDetail | CustomerHistoryCustomerDetail;
+    }>;
+}
+```
+
+<div className="members-wrapper">
+
+### type
+
+<MemberInfo kind="property" type={`string`}   />
+
+The `type` should correspond to a valid `HistoryEntryType`, such as
+
+- `CUSTOMER_REGISTERED`
+- `ORDER_STATE_TRANSITION`
+- some custom type - see the <a href='/reference/typescript-api/services/history-service#historyservice'>HistoryService</a> docs for a guide on
+  how to define custom history entry types.
+### component
+
+<MemberInfo kind="property" type={`React.ComponentType&#60;{         entry: <a href='/reference/dashboard/extensions-api/history-entries#historyentryitem'>HistoryEntryItem</a>;         entity: OrderHistoryOrderDetail | CustomerHistoryCustomerDetail;     }&#62;`}   />
+
+The component which is used to render the timeline entry. It should use the
+<a href='/reference/dashboard/extensions-api/history-entries#historyentry'>HistoryEntry</a> component and pass the appropriate props to configure
+how it will be displayed.
+
+The `entity` prop will be a subset of the Order object for Order history entries,
+or a subset of the Customer object for customer history entries.
+
+
+</div>
+
+
+## HistoryEntryItem
+
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/types/history-entries.ts" sourceLine="14" packageName="@vendure/dashboard" since="3.4.3" />
+
+This object contains the information about the history entry.
+
+```ts title="Signature"
+interface HistoryEntryItem {
+    id: string;
+    type: string;
+    createdAt: string;
+    isPublic: boolean;
+    administrator?: {
+        id: string;
+        firstName: string;
+        lastName: string;
+    } | null;
+    data: any;
+}
+```
+
+<div className="members-wrapper">
+
+### id
+
+<MemberInfo kind="property" type={`string`}   />
+
+
+### type
+
+<MemberInfo kind="property" type={`string`}   />
+
+The `HistoryEntryType`, such as `ORDER_STATE_TRANSITION`.
+### createdAt
+
+<MemberInfo kind="property" type={`string`}   />
+
+
+### isPublic
+
+<MemberInfo kind="property" type={`boolean`}   />
+
+Whether this entry is visible to customers via the Shop API
+### administrator
+
+<MemberInfo kind="property" type={`{         id: string;         firstName: string;         lastName: string;     } | null`}   />
+
+If an Administrator created this entry, their details will
+be available here.
+### data
+
+<MemberInfo kind="property" type={`any`}   />
+
+The entry payload data. This will be an object, which is different
+for each type of history entry.
+
+For example, the `CUSTOMER_ADDED_TO_GROUP` data looks like this:
+```json
+{
+  groupName: 'Some Group',
+}
+```
+
+and the `ORDER_STATE_TRANSITION` data looks like this:
+```json
+{
+  from: 'ArrangingPayment',
+  to: 'PaymentSettled',
+}
+```
+
+
+</div>
+
+
+## HistoryEntryProps
+
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/history-entry/history-entry.tsx" sourceLine="14" packageName="@vendure/dashboard" since="3.4.3" />
+
+The props for the <a href='/reference/dashboard/extensions-api/history-entries#historyentry'>HistoryEntry</a> component.
+
+```ts title="Signature"
+interface HistoryEntryProps {
+    entry: HistoryEntryItem;
+    title: string | React.ReactNode;
+    timelineIcon?: React.ReactNode;
+    timelineIconClassName?: string;
+    actorName?: string;
+    children: React.ReactNode;
+    isPrimary?: boolean;
+}
+```
+
+<div className="members-wrapper">
+
+### entry
+
+<MemberInfo kind="property" type={`<a href='/reference/dashboard/extensions-api/history-entries#historyentryitem'>HistoryEntryItem</a>`}   />
+
+The entry itself, which will get passed down to your custom component
+### title
+
+<MemberInfo kind="property" type={`string | React.ReactNode`}   />
+
+The title of the entry
+### timelineIcon
+
+<MemberInfo kind="property" type={`React.ReactNode`}   />
+
+An icon which is used to represent the entry. Note that this will only
+display if `isPrimary` is `true`.
+### timelineIconClassName
+
+<MemberInfo kind="property" type={`string`}   />
+
+Optional tailwind classes to apply to the icon. For instance
+
+```ts
+const success = 'bg-success text-success-foreground';
+const destructive = 'bg-danger text-danger-foreground';
+```
+### actorName
+
+<MemberInfo kind="property" type={`string`}   />
+
+The name to display of "who did the action". For instance:
+
+```ts
+const getActorName = (entry: HistoryEntryItem) => {
+    if (entry.administrator) {
+        return `${entry.administrator.firstName} ${entry.administrator.lastName}`;
+    } else if (entity?.customer) {
+        return `${entity.customer.firstName} ${entity.customer.lastName}`;
+    }
+    return '';
+};
+```
+### children
+
+<MemberInfo kind="property" type={`React.ReactNode`}   />
+
+
+### isPrimary
+
+<MemberInfo kind="property" type={`boolean`}   />
+
+When set to `true`, the timeline entry will feature the specified icon and will not
+be collapsible.
+
+
+</div>
+
+
+## HistoryEntry
+
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/history-entry/history-entry.tsx" sourceLine="74" packageName="@vendure/dashboard" since="3.4.3" />
+
+A component which is used to display a history entry in the order/customer history timeline.
+
+```ts title="Signature"
+function HistoryEntry(props: Readonly<HistoryEntryProps>): void
+```
+Parameters
+
+### props
+
+<MemberInfo kind="parameter" type={`Readonly&#60;<a href='/reference/dashboard/extensions-api/history-entries#historyentryprops'>HistoryEntryProps</a>&#62;`} />
+

+ 5 - 2
docs/docs/reference/dashboard/list-views/list-page.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## ListPage
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/page/list-page.tsx" sourceLine="158" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/page/list-page.tsx" sourceLine="165" packageName="@vendure/dashboard" since="3.3.0" />
 
 Auto-generates a list page with columns generated based on the provided query document fields.
 
@@ -169,7 +169,10 @@ interface ListPageProps<T extends TypedDocumentNode<U, V>, U extends ListQuerySh
 
 <MemberInfo kind="property" type={`TypedDocumentNode&#60;any, { id: string }&#62;`}   />
 
-
+Providing the `deleteMutation` will automatically add a "delete" menu item to the
+actions column dropdown. Note that if this table already has a "delete" bulk action,
+you don't need to additionally provide a delete mutation, because the bulk action
+will be added to the action column dropdown already.
 ### transformVariables
 
 <MemberInfo kind="property" type={`(variables: V) =&#62; V`}   />

+ 2 - 2
docs/docs/reference/dashboard/page-layout/index.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## PageLayout
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="204" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="211" packageName="@vendure/dashboard" since="3.3.0" />
 
 *
 This component governs the layout of the contents of a <a href='/reference/dashboard/page-layout/page#page'>Page</a> component.
@@ -30,7 +30,7 @@ Parameters
 
 ## PageLayoutProps
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="176" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="183" packageName="@vendure/dashboard" since="3.3.0" />
 
 **Status: Developer Preview**
 

+ 3 - 3
docs/docs/reference/dashboard/page-layout/page-action-bar.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## PageActionBar
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="305" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="312" packageName="@vendure/dashboard" since="3.3.0" />
 
 *
 A component for displaying the main actions for a page. This should be used inside the <a href='/reference/dashboard/page-layout/page#page'>Page</a> component.
@@ -31,7 +31,7 @@ Parameters
 
 ## PageActionBarLeft
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="327" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="334" packageName="@vendure/dashboard" since="3.3.0" />
 
 The PageActionBarLeft component should be used to display the left content of the action bar.
 
@@ -48,7 +48,7 @@ Parameters
 
 ## PageActionBarRight
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="341" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="427" packageName="@vendure/dashboard" since="3.3.0" />
 
 The PageActionBarRight component should be used to display the right content of the action bar.
 

+ 4 - 4
docs/docs/reference/dashboard/page-layout/page-block.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## PageBlock
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="465" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="552" packageName="@vendure/dashboard" since="3.3.0" />
 
 *
 A component for displaying a block of content on a page. This should be used inside the <a href='/reference/dashboard/page-layout/#pagelayout'>PageLayout</a> component.
@@ -39,7 +39,7 @@ Parameters
 
 ## PageBlockProps
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="414" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="501" packageName="@vendure/dashboard" since="3.3.0" />
 
 Props used to configure the <a href='/reference/dashboard/page-layout/page-block#pageblock'>PageBlock</a> component.
 
@@ -93,7 +93,7 @@ An optional set of CSS classes to apply to the block.
 
 ## FullWidthPageBlock
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="510" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="597" packageName="@vendure/dashboard" since="3.3.0" />
 
 **Status: Developer Preview**
 
@@ -113,7 +113,7 @@ Parameters
 
 ## CustomFieldsPageBlock
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="540" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="627" packageName="@vendure/dashboard" since="3.3.0" />
 
 *
 A component for displaying an auto-generated form for custom fields on a page.

+ 1 - 1
docs/docs/reference/dashboard/page-layout/page-title.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## PageTitle
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="290" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="297" packageName="@vendure/dashboard" since="3.3.0" />
 
 A component for displaying the title of a page. This should be used inside the <a href='/reference/dashboard/page-layout/page#page'>Page</a> component.
 

+ 2 - 2
docs/docs/reference/dashboard/page-layout/page.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## Page
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="84" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="91" packageName="@vendure/dashboard" since="3.3.0" />
 
 This component should be used to wrap _all_ pages in the dashboard. It provides
 a consistent layout as well as a context for the slot-based PageBlock system.
@@ -61,7 +61,7 @@ Parameters
 
 ## PageProps
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="31" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="38" packageName="@vendure/dashboard" since="3.3.0" />
 
 The props used to configure the <a href='/reference/dashboard/page-layout/page#page'>Page</a> component.
 

+ 1 - 1
packages/core/src/service/services/channel.service.ts

@@ -187,7 +187,7 @@ export class ChannelService {
         // so that this join could be done prior to invoking this method.
         // TODO: overload the assignToChannels method to allow it to take an entity instance
         if (entityType === (Order as any)) {
-            relations.push('lines', 'shippingLines');
+            relations.push('lines', 'shippingLines', 'surcharges');
         }
         const entity = await this.connection.getEntityOrThrow(ctx, entityType, entityId, {
             loadEagerRelations: false,

+ 11 - 12
packages/dashboard/index.html

@@ -1,15 +1,14 @@
 <!doctype html>
 <html lang="en">
-    <head>
-        <meta charset="UTF-8" />
-        <link rel="icon" type="image/png" href="/favicon.png" />
-        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-        <meta name="description" content="Vendure Admin Dashboard" />
-        <meta name="author" content="Vendure" />
-        <title>Vendure Admin Dashboard</title>
-    </head>
-    <body>
-        <div id="app"></div>
-        <script type="module" src="/src/app/main.jsx"></script>
-    </body>
+<head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/png" href="/favicon.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="description" content="Vendure Admin Dashboard" />
+    <meta name="author" content="Vendure" />
+</head>
+<body>
+<div id="app"></div>
+<script type="module" src="/src/app/main.jsx"></script>
+</body>
 </html>

+ 263 - 0
packages/dashboard/src/app/common/use-page-title.test.ts

@@ -0,0 +1,263 @@
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+import { normalizeBreadcrumb } from './use-page-title.js';
+
+describe('normalizeBreadcrumb', () => {
+    describe('null and undefined handling', () => {
+        it('should return empty string for null', () => {
+            expect(normalizeBreadcrumb(null)).toBe('');
+        });
+
+        it('should return empty string for undefined', () => {
+            expect(normalizeBreadcrumb(undefined)).toBe('');
+        });
+
+        it('should return empty string for empty array', () => {
+            expect(normalizeBreadcrumb([])).toBe('');
+        });
+    });
+
+    describe('string handling', () => {
+        it('should return string as-is', () => {
+            expect(normalizeBreadcrumb('Home')).toBe('Home');
+        });
+
+        it('should return empty string as-is', () => {
+            expect(normalizeBreadcrumb('')).toBe('');
+        });
+
+        it('should handle strings with special characters', () => {
+            expect(normalizeBreadcrumb('Settings & Config')).toBe('Settings & Config');
+        });
+    });
+
+    describe('number handling', () => {
+        it('should convert number to string', () => {
+            expect(normalizeBreadcrumb(42)).toBe('42');
+        });
+
+        it('should handle zero', () => {
+            expect(normalizeBreadcrumb(0)).toBe('0');
+        });
+
+        it('should handle negative numbers', () => {
+            expect(normalizeBreadcrumb(-123)).toBe('-123');
+        });
+
+        it('should handle decimal numbers', () => {
+            expect(normalizeBreadcrumb(3.14)).toBe('3.14');
+        });
+    });
+
+    describe('function handling', () => {
+        it('should call function and normalize the result', () => {
+            const fn = () => 'Dashboard';
+            expect(normalizeBreadcrumb(fn)).toBe('Dashboard');
+        });
+
+        it('should handle nested function returns', () => {
+            const fn = () => () => 'Nested Function';
+            expect(normalizeBreadcrumb(fn)).toBe('Nested Function');
+        });
+
+        it('should handle function returning React element', () => {
+            const mockReactElement = React.createElement('div', {}, 'React Content');
+            const fn = () => mockReactElement;
+            expect(normalizeBreadcrumb(fn)).toBe('React Content');
+        });
+
+        it('should handle function returning array', () => {
+            const fn = () => ['First', 'Second', 'Third'];
+            expect(normalizeBreadcrumb(fn)).toBe('Third');
+        });
+
+        it('should handle function returning object with label', () => {
+            const fn = () => ({ label: 'Settings', path: '/settings' });
+            expect(normalizeBreadcrumb(fn)).toBe('Settings');
+        });
+    });
+
+    describe('array handling', () => {
+        it('should return last element of string array', () => {
+            expect(normalizeBreadcrumb(['Home', 'Products', 'Details'])).toBe('Details');
+        });
+
+        it('should handle single element array', () => {
+            expect(normalizeBreadcrumb(['Single'])).toBe('Single');
+        });
+
+        it('should handle array with React element at the end', () => {
+            const mockReactElement = React.createElement('span', {}, 'Last Item');
+            expect(normalizeBreadcrumb(['First', mockReactElement])).toBe('Last Item');
+        });
+
+        it('should handle array with object containing label at the end', () => {
+            const breadcrumbs = [
+                'Home',
+                { label: 'Products', path: '/products' },
+                { label: 'Details', path: '/products/123' },
+            ];
+            expect(normalizeBreadcrumb(breadcrumbs)).toBe('Details');
+        });
+
+        it('should handle array with function at the end', () => {
+            const breadcrumbs = ['Home', () => 'Dynamic Content'];
+            expect(normalizeBreadcrumb(breadcrumbs)).toBe('Dynamic Content');
+        });
+
+        it('should handle nested arrays', () => {
+            const breadcrumbs = ['First', ['Nested1', 'Nested2']];
+            expect(normalizeBreadcrumb(breadcrumbs)).toBe('Nested2');
+        });
+    });
+
+    describe('object with label property handling', () => {
+        it('should extract string label from object', () => {
+            const breadcrumb = { label: 'Settings', path: '/settings' };
+            expect(normalizeBreadcrumb(breadcrumb)).toBe('Settings');
+        });
+
+        it('should handle React element as label', () => {
+            const mockReactElement = React.createElement('span', {}, 'Global Settings');
+            const breadcrumb = { label: mockReactElement, path: '/global-settings' };
+            expect(normalizeBreadcrumb(breadcrumb)).toBe('Global Settings');
+        });
+
+        it('should handle function as label', () => {
+            const breadcrumb = {
+                label: () => 'Dynamic Label',
+                path: '/dynamic',
+            };
+            expect(normalizeBreadcrumb(breadcrumb)).toBe('Dynamic Label');
+        });
+
+        it('should handle nested object with label', () => {
+            const breadcrumb = {
+                label: {
+                    label: 'Nested Label',
+                },
+                path: '/nested',
+            };
+            expect(normalizeBreadcrumb(breadcrumb)).toBe('Nested Label');
+        });
+
+        it('should handle object with label containing array', () => {
+            const breadcrumb = {
+                label: ['First', 'Second', 'Third'],
+                path: '/array-label',
+            };
+            expect(normalizeBreadcrumb(breadcrumb)).toBe('Third');
+        });
+    });
+
+    describe('React element handling', () => {
+        it('should extract text from simple React element', () => {
+            const element = React.createElement('div', {}, 'Simple Text');
+            expect(normalizeBreadcrumb(element)).toBe('Simple Text');
+        });
+
+        it('should handle nested React elements', () => {
+            const innerElement = React.createElement('span', {}, 'Nested Text');
+            const element = React.createElement('div', {}, innerElement);
+            expect(normalizeBreadcrumb(element)).toBe('Nested Text');
+        });
+
+        it('should handle React element with array of children', () => {
+            const element = React.createElement('div', {}, ['Part 1', ' ', 'Part 2']);
+            expect(normalizeBreadcrumb(element)).toBe('Part 1 Part 2');
+        });
+
+        it('should handle React element with mixed children types', () => {
+            const nestedElement = React.createElement('span', {}, ' nested');
+            const element = React.createElement('div', {}, ['Text', 42, nestedElement]);
+            expect(normalizeBreadcrumb(element)).toBe('Text42 nested');
+        });
+    });
+
+    describe('complex nested scenarios', () => {
+        it('should handle array with object containing function returning React element', () => {
+            const mockReactElement = React.createElement('div', {}, 'Complex Content');
+            const breadcrumb = [
+                'Home',
+                {
+                    label: () => mockReactElement,
+                    path: '/complex',
+                },
+            ];
+            expect(normalizeBreadcrumb(breadcrumb)).toBe('Complex Content');
+        });
+
+        it('should handle function returning array with object containing React element', () => {
+            const mockReactElement = React.createElement('div', {}, 'Deep Nested');
+            const fn = () => ['Start', { label: mockReactElement, path: '/deep' }];
+            expect(normalizeBreadcrumb(fn)).toBe('Deep Nested');
+        });
+
+        it('should handle array with multiple levels of nesting', () => {
+            const breadcrumb = [
+                'Level1',
+                [
+                    'Level2-1',
+                    {
+                        label: () => ['Level3-1', 'Level3-2'],
+                        path: '/multi-level',
+                    },
+                ],
+            ];
+            expect(normalizeBreadcrumb(breadcrumb)).toBe('Level3-2');
+        });
+
+        it('should handle Trans-like component structure', () => {
+            // Simulating a <Trans>Global Settings</Trans> component
+            const transElement = React.createElement(
+                'Trans',
+                {
+                    i18nKey: 'global.settings',
+                },
+                'Global Settings',
+            );
+            const breadcrumb = [
+                {
+                    path: '/global-settings',
+                    label: transElement,
+                },
+            ];
+            expect(normalizeBreadcrumb(breadcrumb)).toBe('Global Settings');
+        });
+    });
+
+    describe('edge cases', () => {
+        it('should handle boolean values', () => {
+            expect(normalizeBreadcrumb(true)).toBe('');
+            expect(normalizeBreadcrumb(false)).toBe('');
+        });
+
+        it('should handle objects without label property', () => {
+            const obj = { path: '/test', name: 'Test' };
+            expect(normalizeBreadcrumb(obj)).toBe('');
+        });
+
+        it('should handle symbols', () => {
+            const sym = Symbol('test');
+            expect(normalizeBreadcrumb(sym)).toBe('');
+        });
+
+        it('should handle circular references gracefully', () => {
+            const circular: any = { label: null };
+            circular.label = circular;
+            // This should not cause infinite recursion
+            // The function should detect the circular reference and return empty string
+            expect(() => normalizeBreadcrumb(circular)).not.toThrow();
+            expect(normalizeBreadcrumb(circular)).toBe('');
+        });
+
+        it('should handle very deeply nested structures', () => {
+            let deep: any = 'Final Value';
+            for (let i = 0; i < 100; i++) {
+                deep = { label: deep };
+            }
+            expect(normalizeBreadcrumb(deep)).toBe('Final Value');
+        });
+    });
+});

+ 86 - 0
packages/dashboard/src/app/common/use-page-title.ts

@@ -0,0 +1,86 @@
+import { useMatches } from '@tanstack/react-router';
+import React, { isValidElement, ReactElement, useEffect, useState } from 'react';
+
+const DEFAULT_TITLE = 'Vendure';
+
+/**
+ * @description
+ * Derives the meta title of the page based on the current route's breadcrumb
+ * data from the route loader.
+ */
+export function usePageTitle() {
+    const matches = useMatches();
+    const [pageTitle, setPageTitle] = useState<string>(DEFAULT_TITLE);
+
+    useEffect(() => {
+        const lastMatch = matches.at(-1);
+        const breadcrumb = (lastMatch?.loaderData as any)?.breadcrumb;
+
+        const breadcrumbTitle = normalizeBreadcrumb(breadcrumb);
+        setPageTitle([breadcrumbTitle, DEFAULT_TITLE].filter(x => !!x).join(' • '));
+    }, [matches]);
+
+    return pageTitle;
+}
+
+const renderNodeAsString = function (reactNode: React.ReactNode): string {
+    let string = '';
+    if (typeof reactNode === 'string') {
+        string = reactNode;
+    } else if (typeof reactNode === 'number') {
+        string = reactNode.toString();
+    } else if (Array.isArray(reactNode)) {
+        reactNode.forEach(function (child) {
+            string += renderNodeAsString(child);
+        });
+    } else if (isValidElement(reactNode)) {
+        string += renderNodeAsString((reactNode as ReactElement<any>).props.children);
+    }
+    return string;
+};
+
+/**
+ * Recursively normalizes a breadcrumb value to a string.
+ * Handles functions, arrays, objects with labels, and React nodes.
+ */
+export const normalizeBreadcrumb = (value: any, visited = new WeakSet()): string => {
+    // Handle null/undefined
+    if (value == null) {
+        return '';
+    }
+
+    // If it's a function, call it and normalize the result
+    if (typeof value === 'function') {
+        return normalizeBreadcrumb(value(), visited);
+    }
+
+    // If it's already a string, return it
+    if (typeof value === 'string') {
+        return value;
+    }
+
+    // If it's an array, normalize the last element
+    if (Array.isArray(value)) {
+        if (value.length === 0) {
+            return '';
+        }
+        return normalizeBreadcrumb(value.at(-1), visited);
+    }
+
+    // For objects, check for circular references
+    if (typeof value === 'object') {
+        // Prevent circular reference infinite loops
+        if (visited.has(value)) {
+            return '';
+        }
+        visited.add(value);
+
+        // If it's an object with a label property, normalize the label
+        if ('label' in value) {
+            return normalizeBreadcrumb(value.label, visited);
+        }
+    }
+
+    // For everything else (React nodes, numbers, etc.), use renderNodeAsString
+    return renderNodeAsString(value);
+};

+ 4 - 4
packages/dashboard/src/app/routes/__root.tsx

@@ -1,6 +1,7 @@
 import { AuthContext } from '@/vdb/providers/auth.js';
 import { QueryClient } from '@tanstack/react-query';
-import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
+import { createRootRouteWithContext, HeadContent, Outlet } from '@tanstack/react-router';
+import { usePageTitle } from '../common/use-page-title.js';
 
 export interface MyRouterContext {
     auth: AuthContext;
@@ -9,14 +10,13 @@ export interface MyRouterContext {
 
 export const Route = createRootRouteWithContext<MyRouterContext>()({
     component: RootComponent,
-    search: {
-        // middlewares: [retainSearchParams(['page', 'perPage', 'sort'] as any)],
-    },
 });
 
 function RootComponent() {
+    document.title = usePageTitle();
     return (
         <>
+            <HeadContent />
             <Outlet />
         </>
     );

+ 2 - 0
packages/dashboard/src/app/routes/_authenticated/_countries/countries.graphql.ts

@@ -38,6 +38,8 @@ export const countryDetailDocument = graphql(`
                 languageCode
                 name
             }
+            createdAt
+            updatedAt
             customFields
         }
     }

+ 2 - 2
packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/customer-history-container.tsx

@@ -63,13 +63,13 @@ export function CustomerHistoryContainer({ customerId }: Readonly<CustomerHistor
         <>
             <CustomerHistory
                 customer={customer}
-                historyEntries={historyEntries ?? []}
+                historyEntries={historyEntries}
                 onAddNote={addNote}
                 onUpdateNote={updateNote}
                 onDeleteNote={deleteNote}
             />
             {hasNextPage && (
-                <Button type="button" variant="outline" onClick={() => fetchNextPage()}>
+                <Button type="button" variant="outline" onClick={() => fetchNextPage?.()}>
                     <Trans>Load more</Trans>
                 </Button>
             )}

+ 5 - 0
packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/customer-history-types.ts

@@ -0,0 +1,5 @@
+import { ResultOf } from 'gql.tada';
+
+import { customerHistoryDocument } from '../../customers.graphql.js';
+
+export type CustomerHistoryCustomerDetail = NonNullable<ResultOf<typeof customerHistoryDocument>>['customer'];

+ 124 - 0
packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/customer-history-utils.tsx

@@ -0,0 +1,124 @@
+import { HistoryEntryItem } from '@/vdb/framework/extension-api/types/index.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { CheckIcon, Edit3, KeyIcon, Mail, MapPin, SquarePen, User, UserCheck, Users } from 'lucide-react';
+import { CustomerHistoryCustomerDetail } from './customer-history-types.js';
+
+export function customerHistoryUtils(customer: CustomerHistoryCustomerDetail) {
+    const getTimelineIcon = (entry: HistoryEntryItem) => {
+        switch (entry.type) {
+            case 'CUSTOMER_REGISTERED':
+                return <User className="h-4 w-4" />;
+            case 'CUSTOMER_VERIFIED':
+                return <UserCheck className="h-4 w-4" />;
+            case 'CUSTOMER_NOTE':
+                return <SquarePen className="h-4 w-4" />;
+            case 'CUSTOMER_ADDED_TO_GROUP':
+            case 'CUSTOMER_REMOVED_FROM_GROUP':
+                return <Users className="h-4 w-4" />;
+            case 'CUSTOMER_DETAIL_UPDATED':
+                return <Edit3 className="h-4 w-4" />;
+            case 'CUSTOMER_ADDRESS_CREATED':
+            case 'CUSTOMER_ADDRESS_UPDATED':
+            case 'CUSTOMER_ADDRESS_DELETED':
+                return <MapPin className="h-4 w-4" />;
+            case 'CUSTOMER_PASSWORD_UPDATED':
+            case 'CUSTOMER_PASSWORD_RESET_REQUESTED':
+            case 'CUSTOMER_PASSWORD_RESET_VERIFIED':
+                return <KeyIcon className="h-4 w-4" />;
+            case 'CUSTOMER_EMAIL_UPDATE_REQUESTED':
+            case 'CUSTOMER_EMAIL_UPDATE_VERIFIED':
+                return <Mail className="h-4 w-4" />;
+            default:
+                return <CheckIcon className="h-4 w-4" />;
+        }
+    };
+
+    const getTitle = (entry: HistoryEntryItem) => {
+        switch (entry.type) {
+            case 'CUSTOMER_REGISTERED':
+                return <Trans>Customer registered</Trans>;
+            case 'CUSTOMER_VERIFIED':
+                return <Trans>Customer verified</Trans>;
+            case 'CUSTOMER_NOTE':
+                return <Trans>Note added</Trans>;
+            case 'CUSTOMER_DETAIL_UPDATED':
+                return <Trans>Customer details updated</Trans>;
+            case 'CUSTOMER_ADDED_TO_GROUP':
+                return <Trans>Added to group</Trans>;
+            case 'CUSTOMER_REMOVED_FROM_GROUP':
+                return <Trans>Removed from group</Trans>;
+            case 'CUSTOMER_ADDRESS_CREATED':
+                return <Trans>Address created</Trans>;
+            case 'CUSTOMER_ADDRESS_UPDATED':
+                return <Trans>Address updated</Trans>;
+            case 'CUSTOMER_ADDRESS_DELETED':
+                return <Trans>Address deleted</Trans>;
+            case 'CUSTOMER_PASSWORD_UPDATED':
+                return <Trans>Password updated</Trans>;
+            case 'CUSTOMER_PASSWORD_RESET_REQUESTED':
+                return <Trans>Password reset requested</Trans>;
+            case 'CUSTOMER_PASSWORD_RESET_VERIFIED':
+                return <Trans>Password reset verified</Trans>;
+            case 'CUSTOMER_EMAIL_UPDATE_REQUESTED':
+                return <Trans>Email update requested</Trans>;
+            case 'CUSTOMER_EMAIL_UPDATE_VERIFIED':
+                return <Trans>Email update verified</Trans>;
+            default:
+                return <Trans>{entry.type.replace(/_/g, ' ').toLowerCase()}</Trans>;
+        }
+    };
+
+    const getIconColor = (entry: HistoryEntryItem) => {
+        // Check for success states
+        if (
+            entry.type === 'CUSTOMER_VERIFIED' ||
+            entry.type === 'CUSTOMER_EMAIL_UPDATE_VERIFIED' ||
+            entry.type === 'CUSTOMER_PASSWORD_RESET_VERIFIED'
+        ) {
+            return 'bg-success text-success-foreground';
+        }
+
+        // Check for destructive states
+        if (entry.type === 'CUSTOMER_REMOVED_FROM_GROUP' || entry.type === 'CUSTOMER_ADDRESS_DELETED') {
+            return 'bg-destructive text-destructive-foreground';
+        }
+
+        // Registration gets muted style
+        if (entry.type === 'CUSTOMER_REGISTERED') {
+            return 'bg-muted text-muted-foreground';
+        }
+
+        // All other entries use neutral colors
+        return 'bg-muted text-muted-foreground';
+    };
+
+    const getActorName = (entry: HistoryEntryItem) => {
+        if (entry.administrator) {
+            return `${entry.administrator.firstName} ${entry.administrator.lastName}`;
+        } else if (customer) {
+            return `${customer.firstName} ${customer.lastName}`;
+        }
+        return '';
+    };
+
+    const isPrimaryEvent = (entry: HistoryEntryItem) => {
+        switch (entry.type) {
+            case 'CUSTOMER_REGISTERED':
+            case 'CUSTOMER_VERIFIED':
+            case 'CUSTOMER_NOTE':
+            case 'CUSTOMER_EMAIL_UPDATE_VERIFIED':
+            case 'CUSTOMER_PASSWORD_RESET_VERIFIED':
+                return true;
+            default:
+                return false;
+        }
+    };
+
+    return {
+        getTimelineIcon,
+        getTitle,
+        getIconColor,
+        getActorName,
+        isPrimaryEvent,
+    };
+}

+ 91 - 59
packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/customer-history.tsx

@@ -1,81 +1,113 @@
-import { HistoryEntry, HistoryEntryItem } from '@/vdb/components/shared/history-timeline/history-entry.js';
+import { HistoryNoteEditor } from '@/vdb/components/shared/history-timeline/history-note-editor.js';
+import { HistoryNoteEntry } from '@/vdb/components/shared/history-timeline/history-note-entry.js';
 import { HistoryNoteInput } from '@/vdb/components/shared/history-timeline/history-note-input.js';
-import { HistoryTimeline } from '@/vdb/components/shared/history-timeline/history-timeline.js';
-import { Badge } from '@/vdb/components/ui/badge.js';
-import { Trans } from '@/vdb/lib/trans.js';
-import { CheckIcon, SquarePen } from 'lucide-react';
+import { HistoryTimelineWithGrouping } from '@/vdb/components/shared/history-timeline/history-timeline-with-grouping.js';
+import { useHistoryNoteEditor } from '@/vdb/components/shared/history-timeline/use-history-note-editor.js';
+import { HistoryEntryItem } from '@/vdb/framework/extension-api/types/history-entries.js';
+import { HistoryEntryProps } from '@/vdb/framework/history-entry/history-entry.js';
+import { CustomerHistoryCustomerDetail } from './customer-history-types.js';
+import { customerHistoryUtils } from './customer-history-utils.js';
+import {
+    CustomerAddRemoveGroupComponent,
+    CustomerAddressCreatedComponent,
+    CustomerAddressDeletedComponent,
+    CustomerAddressUpdatedComponent,
+    CustomerDetailUpdatedComponent,
+    CustomerEmailUpdateComponent,
+    CustomerPasswordResetRequestedComponent,
+    CustomerPasswordResetVerifiedComponent,
+    CustomerPasswordUpdatedComponent,
+    CustomerRegisteredOrVerifiedComponent,
+} from './default-customer-history-components.js';
 
 interface CustomerHistoryProps {
-    customer: {
-        id: string;
-    };
-    historyEntries: Array<HistoryEntryItem>;
+    customer: CustomerHistoryCustomerDetail;
+    historyEntries: HistoryEntryItem[];
     onAddNote: (note: string, isPrivate: boolean) => void;
     onUpdateNote?: (entryId: string, note: string, isPrivate: boolean) => void;
     onDeleteNote?: (entryId: string) => void;
 }
 
 export function CustomerHistory({
+    customer,
     historyEntries,
     onAddNote,
     onUpdateNote,
     onDeleteNote,
-}: CustomerHistoryProps) {
-    const getTimelineIcon = (entry: CustomerHistoryProps['historyEntries'][0]) => {
-        switch (entry.type) {
-            case 'CUSTOMER_NOTE':
-                return <SquarePen className="h-4 w-4" />;
-            default:
-                return <CheckIcon className="h-4 w-4" />;
-        }
+}: Readonly<CustomerHistoryProps>) {
+    const { noteState, noteEditorOpen, handleEditNote, setNoteEditorOpen } = useHistoryNoteEditor();
+
+    const handleDeleteNote = (noteId: string) => {
+        onDeleteNote?.(noteId);
     };
 
-    const getTitle = (entry: CustomerHistoryProps['historyEntries'][0]) => {
-        switch (entry.type) {
-            case 'CUSTOMER_NOTE':
-                return <Trans>Note added</Trans>;
-            default:
-                return <Trans>{entry.type.replace(/_/g, ' ').toLowerCase()}</Trans>;
+    const handleNoteEditorSave = (noteId: string, note: string, isPrivate: boolean) => {
+        onUpdateNote?.(noteId, note, isPrivate);
+    };
+
+    const { getTimelineIcon, getTitle, getIconColor, getActorName, isPrimaryEvent } =
+        customerHistoryUtils(customer);
+
+    const renderEntryContent = (entry: HistoryEntryItem) => {
+        const props: HistoryEntryProps = {
+            entry,
+            title: getTitle(entry),
+            actorName: getActorName(entry),
+            timelineIcon: getTimelineIcon(entry),
+            timelineIconClassName: getIconColor(entry),
+            isPrimary: isPrimaryEvent(entry),
+            children: null,
+        };
+        if (entry.type === 'CUSTOMER_REGISTERED' || entry.type === 'CUSTOMER_VERIFIED') {
+            return <CustomerRegisteredOrVerifiedComponent {...props} />;
+        } else if (entry.type === 'CUSTOMER_DETAIL_UPDATED') {
+            return <CustomerDetailUpdatedComponent {...props} />;
+        } else if (entry.type === 'CUSTOMER_ADDED_TO_GROUP' || entry.type === 'CUSTOMER_REMOVED_FROM_GROUP') {
+            return <CustomerAddRemoveGroupComponent {...props} />;
+        } else if (entry.type === 'CUSTOMER_ADDRESS_CREATED') {
+            return <CustomerAddressCreatedComponent {...props} />;
+        } else if (entry.type === 'CUSTOMER_ADDRESS_UPDATED') {
+            return <CustomerAddressUpdatedComponent {...props} />;
+        } else if (entry.type === 'CUSTOMER_ADDRESS_DELETED') {
+            return <CustomerAddressDeletedComponent {...props} />;
+        } else if (entry.type === 'CUSTOMER_NOTE') {
+            return (
+                <HistoryNoteEntry {...props} onEditNote={handleEditNote} onDeleteNote={handleDeleteNote} />
+            );
+        } else if (entry.type === 'CUSTOMER_PASSWORD_UPDATED') {
+            return <CustomerPasswordUpdatedComponent {...props} />;
+        } else if (entry.type === 'CUSTOMER_PASSWORD_RESET_REQUESTED') {
+            return <CustomerPasswordResetRequestedComponent {...props} />;
+        } else if (entry.type === 'CUSTOMER_PASSWORD_RESET_VERIFIED') {
+            return <CustomerPasswordResetVerifiedComponent {...props} />;
+        } else if (
+            entry.type === 'CUSTOMER_EMAIL_UPDATE_REQUESTED' ||
+            entry.type === 'CUSTOMER_EMAIL_UPDATE_VERIFIED'
+        ) {
+            return <CustomerEmailUpdateComponent {...props} />;
         }
+        return null;
     };
 
     return (
-        <div className="">
-            <div className="mb-4">
+        <>
+            <HistoryTimelineWithGrouping
+                historyEntries={historyEntries}
+                isPrimaryEvent={isPrimaryEvent}
+                renderEntryContent={renderEntryContent}
+                entity={customer}
+            >
                 <HistoryNoteInput onAddNote={onAddNote} />
-            </div>
-            <HistoryTimeline onEditNote={onUpdateNote} onDeleteNote={onDeleteNote}>
-                {historyEntries.map(entry => (
-                    <HistoryEntry
-                        key={entry.id}
-                        entry={entry}
-                        isNoteEntry={entry.type === 'CUSTOMER_NOTE'}
-                        timelineIcon={getTimelineIcon(entry)}
-                        title={getTitle(entry)}
-                    >
-                        {entry.type === 'CUSTOMER_NOTE' && (
-                            <div className="flex items-center space-x-2">
-                                <Badge variant={entry.isPublic ? 'outline' : 'secondary'} className="text-xs">
-                                    {entry.isPublic ? 'Public' : 'Private'}
-                                </Badge>
-                                <span>{entry.data.note}</span>
-                            </div>
-                        )}
-                        <div className="text-sm text-muted-foreground">
-                            {entry.type === 'CUSTOMER_NOTE' && (
-                                <Trans>
-                                    From {entry.data.from} to {entry.data.to}
-                                </Trans>
-                            )}
-                            {entry.type === 'ORDER_PAYMENT_TRANSITION' && (
-                                <Trans>
-                                    Payment #{entry.data.paymentId} transitioned to {entry.data.to}
-                                </Trans>
-                            )}
-                        </div>
-                    </HistoryEntry>
-                ))}
-            </HistoryTimeline>
-        </div>
+            </HistoryTimelineWithGrouping>
+            <HistoryNoteEditor
+                key={noteState.noteId}
+                open={noteEditorOpen}
+                onOpenChange={setNoteEditorOpen}
+                onNoteChange={handleNoteEditorSave}
+                note={noteState.note}
+                isPrivate={noteState.isPrivate}
+                noteId={noteState.noteId}
+            />
+        </>
     );
 }

+ 176 - 0
packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/default-customer-history-components.tsx

@@ -0,0 +1,176 @@
+import { HistoryEntry, HistoryEntryProps } from '@/vdb/framework/history-entry/history-entry.js';
+import { Trans } from '@/vdb/lib/trans.js';
+
+export function CustomerRegisteredOrVerifiedComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    const strategy = entry.data?.strategy || 'native';
+
+    return (
+        <HistoryEntry {...props}>
+            <div className="space-y-1">
+                {strategy === 'native' ? (
+                    <p className="text-xs text-muted-foreground">
+                        <Trans>Using native authentication strategy</Trans>
+                    </p>
+                ) : (
+                    <p className="text-xs text-muted-foreground">
+                        <Trans>Using external authentication strategy: {strategy}</Trans>
+                    </p>
+                )}
+            </div>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerDetailUpdatedComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <div className="space-y-2">
+                {entry.data?.input && (
+                    <details className="text-xs">
+                        <summary className="cursor-pointer text-muted-foreground hover:text-foreground">
+                            <Trans>View changes</Trans>
+                        </summary>
+                        <pre className="mt-2 p-2 bg-muted rounded text-xs overflow-auto">
+                            {JSON.stringify(entry.data.input, null, 2)}
+                        </pre>
+                    </details>
+                )}
+            </div>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerAddRemoveGroupComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    const groupName = entry.data?.groupName || 'Unknown Group';
+
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">{groupName}</p>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerAddressCreatedComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <div className="space-y-1">
+                <p className="text-xs text-muted-foreground">
+                    <Trans>Address created</Trans>
+                </p>
+                {entry.data?.address && (
+                    <div className="text-xs p-2 bg-muted rounded">{entry.data.address}</div>
+                )}
+            </div>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerAddressUpdatedComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <div className="space-y-2">
+                <p className="text-xs text-muted-foreground">
+                    <Trans>Address updated</Trans>
+                </p>
+                {entry.data?.address && (
+                    <div className="text-xs p-2 bg-muted rounded">{entry.data.address}</div>
+                )}
+                {entry.data?.input && (
+                    <details className="text-xs">
+                        <summary className="cursor-pointer text-muted-foreground hover:text-foreground">
+                            <Trans>View changes</Trans>
+                        </summary>
+                        <pre className="mt-2 p-2 bg-muted rounded text-xs overflow-auto">
+                            {JSON.stringify(entry.data.input, null, 2)}
+                        </pre>
+                    </details>
+                )}
+            </div>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerAddressDeletedComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <div className="space-y-1">
+                <p className="text-xs text-muted-foreground">
+                    <Trans>Address deleted</Trans>
+                </p>
+                {entry.data?.address && (
+                    <div className="text-xs p-2 bg-muted rounded">{entry.data.address}</div>
+                )}
+            </div>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerPasswordUpdatedComponent(props: Readonly<HistoryEntryProps>) {
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>Password updated</Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerPasswordResetRequestedComponent(props: Readonly<HistoryEntryProps>) {
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>Password reset requested</Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerPasswordResetVerifiedComponent(props: Readonly<HistoryEntryProps>) {
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>Password reset verified</Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerEmailUpdateComponent({ entry }: Readonly<HistoryEntryProps>) {
+    const { oldEmailAddress, newEmailAddress } = entry.data || {};
+
+    return (
+        <div className="space-y-2">
+            {(oldEmailAddress || newEmailAddress) && (
+                <details className="text-xs">
+                    <summary className="cursor-pointer text-muted-foreground hover:text-foreground">
+                        <Trans>View details</Trans>
+                    </summary>
+                    <div className="mt-2 space-y-1">
+                        {oldEmailAddress && (
+                            <div>
+                                <span className="font-medium">
+                                    <Trans>Old email:</Trans>
+                                </span>{' '}
+                                {oldEmailAddress}
+                            </div>
+                        )}
+                        {newEmailAddress && (
+                            <div>
+                                <span className="font-medium">
+                                    <Trans>New email:</Trans>
+                                </span>{' '}
+                                {newEmailAddress}
+                            </div>
+                        )}
+                    </div>
+                </details>
+            )}
+        </div>
+    );
+}

+ 4 - 2
packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/index.ts

@@ -1,3 +1,5 @@
-export * from './customer-history.js';
 export * from './customer-history-container.js';
-export * from './use-customer-history.js';
+export * from './customer-history-utils.js';
+export * from './customer-history.js';
+export * from './default-customer-history-components.js';
+export * from './default-customer-history-registry.js';

+ 2 - 0
packages/dashboard/src/app/routes/_authenticated/_customers/customers.graphql.ts

@@ -167,6 +167,8 @@ export const customerHistoryDocument = graphql(`
             id
             createdAt
             updatedAt
+            firstName
+            lastName
             history(options: $options) {
                 totalItems
                 items {

+ 302 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-detail-shared.tsx

@@ -0,0 +1,302 @@
+import { CustomFieldsForm } from '@/vdb/components/shared/custom-fields-form.js';
+import { PermissionGuard } from '@/vdb/components/shared/permission-guard.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import { DropdownMenuItem } from '@/vdb/components/ui/dropdown-menu.js';
+import {
+    Page,
+    PageActionBar,
+    PageActionBarRight,
+    PageBlock,
+    PageLayout,
+    PageTitle,
+} from '@/vdb/framework/layout-engine/page-layout.js';
+import { getDetailQueryOptions, useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
+import { api } from '@/vdb/graphql/api.js';
+import { useCustomFieldConfig } from '@/vdb/hooks/use-custom-field-config.js';
+import { Trans, useLingui } from '@/vdb/lib/trans.js';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { Link, useNavigate } from '@tanstack/react-router';
+import { ResultOf } from 'gql.tada';
+import { Pencil, User } from 'lucide-react';
+import { useMemo } from 'react';
+import { toast } from 'sonner';
+import {
+    orderDetailDocument,
+    setOrderCustomFieldsDocument,
+    transitionOrderToStateDocument,
+} from '../orders.graphql.js';
+import { canAddFulfillment, shouldShowAddManualPaymentButton } from '../utils/order-utils.js';
+import { AddManualPaymentDialog } from './add-manual-payment-dialog.js';
+import { FulfillOrderDialog } from './fulfill-order-dialog.js';
+import { FulfillmentDetails } from './fulfillment-details.js';
+import { OrderAddress } from './order-address.js';
+import { OrderHistoryContainer } from './order-history/order-history-container.js';
+import { orderHistoryQueryKey } from './order-history/use-order-history.js';
+import { OrderTable } from './order-table.js';
+import { OrderTaxSummary } from './order-tax-summary.js';
+import { PaymentDetails } from './payment-details.js';
+import { getTypeForState, StateTransitionControl } from './state-transition-control.js';
+import { useTransitionOrderToState } from './use-transition-order-to-state.js';
+
+export type OrderDetail = NonNullable<ResultOf<typeof orderDetailDocument>['order']>;
+
+export interface OrderDetailSharedProps {
+    // Required props
+    pageId: string;
+    orderId: string;
+    // Title customization
+    titleSlot?: (order: OrderDetail) => React.ReactNode;
+    // Optional content slots
+    beforeOrderTable?: (order: OrderDetail) => React.ReactNode;
+}
+
+function DefaultOrderTitle({ entity }: { entity: any }) {
+    return <>{entity?.code ?? ''}</>;
+}
+
+/**
+ * @description
+ * Shared functionality between the order and seller order detail pages.
+ */
+export function OrderDetailShared({
+    pageId,
+    orderId,
+    titleSlot,
+    beforeOrderTable,
+}: Readonly<OrderDetailSharedProps>) {
+    const { i18n } = useLingui();
+    const navigate = useNavigate();
+    const queryClient = useQueryClient();
+
+    const { form, submitHandler, entity, refreshEntity } = useDetailPage({
+        pageId,
+        queryDocument: orderDetailDocument,
+        updateDocument: setOrderCustomFieldsDocument,
+        setValuesForUpdate: (entity: any) => {
+            return {
+                id: entity.id,
+                customFields: entity.customFields,
+            };
+        },
+        params: { id: orderId },
+        onSuccess: async () => {
+            toast(i18n.t('Successfully updated order'));
+            form.reset(form.getValues());
+        },
+        onError: err => {
+            toast(i18n.t('Failed to update order'), {
+                description: err instanceof Error ? err.message : 'Unknown error',
+            });
+        },
+    });
+
+    const { transitionToState } = useTransitionOrderToState(entity?.id);
+    const transitionOrderToStateMutation = useMutation({
+        mutationFn: api.mutate(transitionOrderToStateDocument),
+    });
+
+    const customFieldConfig = useCustomFieldConfig('Order');
+
+    const stateTransitionActions = useMemo(() => {
+        if (!entity) {
+            return [];
+        }
+        return entity.nextStates.map((state: string) => ({
+            label: `Transition to ${state}`,
+            type: getTypeForState(state),
+            onClick: async () => {
+                const transitionError = await transitionToState(state);
+                if (transitionError) {
+                    toast(i18n.t('Failed to transition order to state'), {
+                        description: transitionError,
+                    });
+                } else {
+                    refreshOrderAndHistory();
+                }
+            },
+        }));
+    }, [entity, transitionToState, i18n]);
+
+    if (!entity) {
+        return null;
+    }
+
+    const handleModifyClick = async () => {
+        try {
+            await transitionOrderToStateMutation.mutateAsync({
+                id: entity.id,
+                state: 'Modifying',
+            });
+            const queryKey = getDetailQueryOptions(orderDetailDocument, { id: entity.id }).queryKey;
+            await queryClient.invalidateQueries({ queryKey });
+            await navigate({ to: `/orders/$id/modify`, params: { id: entity.id } });
+        } catch (error) {
+            toast(i18n.t('Failed to modify order'), {
+                description: error instanceof Error ? error.message : 'Unknown error',
+            });
+        }
+    };
+
+    const nextStates = entity.nextStates;
+    const showAddPaymentButton = shouldShowAddManualPaymentButton(entity);
+    const showFulfillButton = canAddFulfillment(entity);
+
+    async function refreshOrderAndHistory() {
+        if (entity) {
+            const queryKey = getDetailQueryOptions(orderDetailDocument, { id: entity.id }).queryKey;
+            await queryClient.invalidateQueries({ queryKey });
+            queryClient.refetchQueries({ queryKey: orderHistoryQueryKey(entity.id) });
+        }
+    }
+
+    return (
+        <Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
+            <PageTitle>{titleSlot?.(entity) || <DefaultOrderTitle entity={entity} />}</PageTitle>
+            <PageActionBar>
+                <PageActionBarRight
+                    dropdownMenuItems={[
+                        ...(nextStates.includes('Modifying')
+                            ? [
+                                  {
+                                      component: () => (
+                                          <DropdownMenuItem onClick={handleModifyClick}>
+                                              <Pencil className="w-4 h-4" />
+                                              <Trans>Modify</Trans>
+                                          </DropdownMenuItem>
+                                      ),
+                                  },
+                              ]
+                            : []),
+                    ]}
+                >
+                    {showAddPaymentButton && (
+                        <PermissionGuard requires={['UpdateOrder']}>
+                            <AddManualPaymentDialog
+                                order={entity}
+                                onSuccess={() => {
+                                    refreshEntity();
+                                }}
+                            />
+                        </PermissionGuard>
+                    )}
+                    {showFulfillButton && (
+                        <PermissionGuard requires={['UpdateOrder']}>
+                            <FulfillOrderDialog
+                                order={entity}
+                                onSuccess={() => {
+                                    refreshOrderAndHistory();
+                                }}
+                            />
+                        </PermissionGuard>
+                    )}
+                </PageActionBarRight>
+            </PageActionBar>
+            <PageLayout>
+                {/* Main Column Blocks */}
+                {beforeOrderTable?.(entity)}
+                <PageBlock column="main" blockId="order-table">
+                    <OrderTable order={entity} pageId={pageId} />
+                </PageBlock>
+                <PageBlock column="main" blockId="tax-summary" title={<Trans>Tax summary</Trans>}>
+                    <OrderTaxSummary order={entity} />
+                </PageBlock>
+                {customFieldConfig?.length ? (
+                    <PageBlock column="main" blockId="custom-fields">
+                        <CustomFieldsForm entityType="Order" control={form.control} />
+                        <div className="flex justify-end">
+                            <Button
+                                type="submit"
+                                disabled={!form.formState.isDirty || !form.formState.isValid}
+                            >
+                                <Trans>Save</Trans>
+                            </Button>
+                        </div>
+                    </PageBlock>
+                ) : null}
+                <PageBlock column="main" blockId="payment-details" title={<Trans>Payment details</Trans>}>
+                    <div className="grid lg:grid-cols-2 gap-4">
+                        {entity?.payments?.map((payment: any) => (
+                            <PaymentDetails
+                                key={payment.id}
+                                payment={payment}
+                                currencyCode={entity.currencyCode}
+                                onSuccess={refreshOrderAndHistory}
+                            />
+                        ))}
+                    </div>
+                </PageBlock>
+                <PageBlock column="main" blockId="order-history" title={<Trans>Order history</Trans>}>
+                    <OrderHistoryContainer orderId={orderId} />
+                </PageBlock>
+
+                {/* Side Column Blocks */}
+                <PageBlock column="side" blockId="state">
+                    <StateTransitionControl
+                        currentState={entity?.state}
+                        actions={stateTransitionActions}
+                        isLoading={transitionOrderToStateMutation.isPending}
+                    />
+                </PageBlock>
+                <PageBlock column="side" blockId="customer" title={<Trans>Customer</Trans>}>
+                    {entity?.customer ? (
+                        <Button variant="ghost" asChild>
+                            <Link to={`/customers/${entity.customer.id}`}>
+                                <User className="w-4 h-4" />
+                                {entity.customer.firstName} {entity.customer.lastName}
+                            </Link>
+                        </Button>
+                    ) : (
+                        <div className="text-muted-foreground text-xs font-medium p-3 border rounded-md">
+                            <Trans>No customer</Trans>
+                        </div>
+                    )}
+                    <div className="mt-4 divide-y">
+                        {entity?.shippingAddress && (
+                            <div className="pb-6">
+                                <div className="font-medium">
+                                    <Trans>Shipping address</Trans>
+                                </div>
+                                <OrderAddress address={entity.shippingAddress} />
+                            </div>
+                        )}
+                        {entity?.billingAddress && (
+                            <div className="pt-4">
+                                <div className="font-medium">
+                                    <Trans>Billing address</Trans>
+                                </div>
+                                <OrderAddress address={entity.billingAddress} />
+                            </div>
+                        )}
+                    </div>
+                </PageBlock>
+                <PageBlock
+                    column="side"
+                    blockId="fulfillment-details"
+                    title={<Trans>Fulfillment details</Trans>}
+                >
+                    {entity?.fulfillments?.length && entity.fulfillments.length > 0 ? (
+                        <div className="space-y-2">
+                            {entity?.fulfillments?.map((fulfillment: any) => (
+                                <FulfillmentDetails
+                                    key={fulfillment.id}
+                                    order={entity}
+                                    fulfillment={fulfillment}
+                                    onSuccess={() => {
+                                        refreshEntity();
+                                        queryClient.refetchQueries({
+                                            queryKey: orderHistoryQueryKey(entity.id),
+                                        });
+                                    }}
+                                />
+                            ))}
+                        </div>
+                    ) : (
+                        <div className="text-muted-foreground text-xs font-medium p-3 border rounded-md">
+                            <Trans>No fulfillments</Trans>
+                        </div>
+                    )}
+                </PageBlock>
+            </PageLayout>
+        </Page>
+    );
+}

+ 98 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/default-order-history-components.tsx

@@ -0,0 +1,98 @@
+import { HistoryEntry, HistoryEntryProps } from '@/vdb/framework/history-entry/history-entry.js';
+import { Trans } from '@/vdb/lib/trans.js';
+
+export function OrderStateTransitionComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    if (entry.data.from === 'Created') return null;
+
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>
+                    From {entry.data.from} to {entry.data.to}
+                </Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function OrderPaymentTransitionComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>
+                    Payment #{entry.data.paymentId} transitioned to {entry.data.to}
+                </Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function OrderRefundTransitionComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>
+                    Refund #{entry.data.refundId} transitioned to {entry.data.to}
+                </Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function OrderFulfillmentTransitionComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>
+                    Fulfillment #{entry.data.fulfillmentId} from {entry.data.from} to {entry.data.to}
+                </Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function OrderFulfillmentComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>Fulfillment #{entry.data.fulfillmentId} created</Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function OrderModifiedComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>Order modification #{entry.data.modificationId}</Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function OrderCustomerUpdatedComponent(props: Readonly<HistoryEntryProps>) {
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>Customer information updated</Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function OrderCancellationComponent(props: Readonly<HistoryEntryProps>) {
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>Order cancelled</Trans>
+            </p>
+        </HistoryEntry>
+    );
+}

+ 9 - 7
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/order-history-container.tsx

@@ -61,13 +61,15 @@ export function OrderHistoryContainer({ orderId }: Readonly<OrderHistoryContaine
 
     return (
         <>
-            <OrderHistory
-                order={order}
-                historyEntries={historyEntries ?? []}
-                onAddNote={addNote}
-                onUpdateNote={updateNote}
-                onDeleteNote={deleteNote}
-            />
+            {order ? (
+                <OrderHistory
+                    order={order}
+                    historyEntries={historyEntries ?? []}
+                    onAddNote={addNote}
+                    onUpdateNote={updateNote}
+                    onDeleteNote={deleteNote}
+                />
+            ) : null}
             {hasNextPage && (
                 <Button type="button" variant="outline" onClick={() => fetchNextPage()}>
                     <Trans>Load more</Trans>

+ 5 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/order-history-types.ts

@@ -0,0 +1,5 @@
+import { ResultOf } from 'gql.tada';
+
+import { orderHistoryDocument } from '../../orders.graphql.js';
+
+export type OrderHistoryOrderDetail = NonNullable<ResultOf<typeof orderHistoryDocument>>['order'];

+ 173 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/order-history-utils.tsx

@@ -0,0 +1,173 @@
+import { HistoryEntryItem } from '@/vdb/framework/extension-api/types/index.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import {
+    ArrowRightToLine,
+    Ban,
+    CheckIcon,
+    CreditCardIcon,
+    Edit3,
+    SquarePen,
+    Truck,
+    UserX,
+} from 'lucide-react';
+import { OrderHistoryOrderDetail } from './order-history-types.js';
+
+export function orderHistoryUtils(order: OrderHistoryOrderDetail) {
+    const getTimelineIcon = (entry: HistoryEntryItem) => {
+        switch (entry.type) {
+            case 'ORDER_PAYMENT_TRANSITION':
+                return <CreditCardIcon className="h-4 w-4" />;
+            case 'ORDER_REFUND_TRANSITION':
+                return <CreditCardIcon className="h-4 w-4" />;
+            case 'ORDER_NOTE':
+                return <SquarePen className="h-4 w-4" />;
+            case 'ORDER_STATE_TRANSITION':
+                if (entry.data.to === 'Delivered') {
+                    return <CheckIcon className="h-4 w-4" />;
+                }
+                if (entry.data.to === 'Cancelled') {
+                    return <Ban className="h-4 w-4" />;
+                }
+                return <ArrowRightToLine className="h-4 w-4" />;
+            case 'ORDER_FULFILLMENT_TRANSITION':
+                if (entry.data.to === 'Shipped' || entry.data.to === 'Delivered') {
+                    return <Truck className="h-4 w-4" />;
+                }
+                return <ArrowRightToLine className="h-4 w-4" />;
+            case 'ORDER_FULFILLMENT':
+                return <Truck className="h-4 w-4" />;
+            case 'ORDER_MODIFIED':
+                return <Edit3 className="h-4 w-4" />;
+            case 'ORDER_CUSTOMER_UPDATED':
+                return <UserX className="h-4 w-4" />;
+            case 'ORDER_CANCELLATION':
+                return <Ban className="h-4 w-4" />;
+            default:
+                return <CheckIcon className="h-4 w-4" />;
+        }
+    };
+
+    const getTitle = (entry: HistoryEntryItem) => {
+        switch (entry.type) {
+            case 'ORDER_PAYMENT_TRANSITION':
+                if (entry.data.to === 'Settled') {
+                    return <Trans>Payment settled</Trans>;
+                }
+                if (entry.data.to === 'Authorized') {
+                    return <Trans>Payment authorized</Trans>;
+                }
+                if (entry.data.to === 'Declined' || entry.data.to === 'Cancelled') {
+                    return <Trans>Payment failed</Trans>;
+                }
+                return <Trans>Payment transitioned</Trans>;
+            case 'ORDER_REFUND_TRANSITION':
+                if (entry.data.to === 'Settled') {
+                    return <Trans>Refund settled</Trans>;
+                }
+                return <Trans>Refund transitioned</Trans>;
+            case 'ORDER_NOTE':
+                return <Trans>Note added</Trans>;
+            case 'ORDER_STATE_TRANSITION': {
+                if (entry.data.from === 'Created') {
+                    return <Trans>Order placed</Trans>;
+                }
+                if (entry.data.to === 'Delivered') {
+                    return <Trans>Order fulfilled</Trans>;
+                }
+                if (entry.data.to === 'Cancelled') {
+                    return <Trans>Order cancelled</Trans>;
+                }
+                if (entry.data.to === 'Shipped') {
+                    return <Trans>Order shipped</Trans>;
+                }
+                return <Trans>Order transitioned</Trans>;
+            }
+            case 'ORDER_FULFILLMENT_TRANSITION':
+                if (entry.data.to === 'Shipped') {
+                    return <Trans>Order shipped</Trans>;
+                }
+                if (entry.data.to === 'Delivered') {
+                    return <Trans>Order delivered</Trans>;
+                }
+                return <Trans>Fulfillment transitioned</Trans>;
+            case 'ORDER_FULFILLMENT':
+                return <Trans>Fulfillment created</Trans>;
+            case 'ORDER_MODIFIED':
+                return <Trans>Order modified</Trans>;
+            case 'ORDER_CUSTOMER_UPDATED':
+                return <Trans>Customer updated</Trans>;
+            case 'ORDER_CANCELLATION':
+                return <Trans>Order cancelled</Trans>;
+            default:
+                return <Trans>{entry.type.replace(/_/g, ' ').toLowerCase()}</Trans>;
+        }
+    };
+
+    const getIconColor = ({ type, data }: HistoryEntryItem) => {
+        const success = 'bg-success text-success-foreground';
+        const destructive = 'bg-danger text-danger-foreground';
+        const regular = 'bg-muted text-muted-foreground';
+
+        if (type === 'ORDER_PAYMENT_TRANSITION' && data.to === 'Settled') {
+            return success;
+        }
+        if (type === 'ORDER_STATE_TRANSITION' && data.to === 'Delivered') {
+            return success;
+        }
+        if (type === 'ORDER_FULFILLMENT_TRANSITION' && data.to === 'Delivered') {
+            return success;
+        }
+        if (type === 'ORDER_CANCELLATION') {
+            return destructive;
+        }
+        if (type === 'ORDER_STATE_TRANSITION' && data.to === 'Cancelled') {
+            return destructive;
+        }
+        if (type === 'ORDER_PAYMENT_TRANSITION' && (data.to === 'Declined' || data.to === 'Cancelled')) {
+            return destructive;
+        }
+        return regular;
+    };
+
+    const getActorName = (entry: HistoryEntryItem) => {
+        if (entry.administrator) {
+            return `${entry.administrator.firstName} ${entry.administrator.lastName}`;
+        } else if (order?.customer) {
+            return `${order.customer.firstName} ${order.customer.lastName}`;
+        }
+        return '';
+    };
+
+    const isPrimaryEvent = (entry: HistoryEntryItem) => {
+        switch (entry.type) {
+            case 'ORDER_STATE_TRANSITION':
+                return (
+                    entry.data.to === 'Delivered' ||
+                    entry.data.to === 'Cancelled' ||
+                    entry.data.to === 'Settled' ||
+                    entry.data.from === 'Created'
+                );
+            case 'ORDER_REFUND_TRANSITION':
+                return entry.data.to === 'Settled';
+            case 'ORDER_PAYMENT_TRANSITION':
+                return entry.data.to === 'Settled' || entry.data.to === 'Cancelled';
+            case 'ORDER_FULFILLMENT_TRANSITION':
+                return entry.data.to === 'Delivered' || entry.data.to === 'Shipped';
+            case 'ORDER_NOTE':
+            case 'ORDER_MODIFIED':
+            case 'ORDER_CUSTOMER_UPDATED':
+            case 'ORDER_CANCELLATION':
+                return true;
+            default:
+                return false; // All other events are secondary
+        }
+    };
+
+    return {
+        getTimelineIcon,
+        getTitle,
+        getIconColor,
+        getActorName,
+        isPrimaryEvent,
+    };
+}

+ 64 - 408
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/order-history.tsx

@@ -1,34 +1,25 @@
-import { HistoryEntry, HistoryEntryItem } from '@/vdb/components/shared/history-timeline/history-entry.js';
 import { HistoryNoteEditor } from '@/vdb/components/shared/history-timeline/history-note-editor.js';
+import { HistoryNoteEntry } from '@/vdb/components/shared/history-timeline/history-note-entry.js';
 import { HistoryNoteInput } from '@/vdb/components/shared/history-timeline/history-note-input.js';
-import { HistoryTimeline } from '@/vdb/components/shared/history-timeline/history-timeline.js';
-import { Badge } from '@/vdb/components/ui/badge.js';
-import { Button } from '@/vdb/components/ui/button.js';
-import { Trans } from '@/vdb/lib/trans.js';
+import { HistoryTimelineWithGrouping } from '@/vdb/components/shared/history-timeline/history-timeline-with-grouping.js';
+import { useHistoryNoteEditor } from '@/vdb/components/shared/history-timeline/use-history-note-editor.js';
+import { HistoryEntryItem } from '@/vdb/framework/extension-api/types/index.js';
+import { HistoryEntryProps } from '@/vdb/framework/history-entry/history-entry.js';
 import {
-    ArrowRightToLine,
-    Ban,
-    CheckIcon,
-    ChevronDown,
-    ChevronUp,
-    CreditCardIcon,
-    Edit3,
-    SquarePen,
-    Truck,
-    UserX,
-} from 'lucide-react';
-import { useState } from 'react';
+    OrderCancellationComponent,
+    OrderCustomerUpdatedComponent,
+    OrderFulfillmentComponent,
+    OrderFulfillmentTransitionComponent,
+    OrderModifiedComponent,
+    OrderPaymentTransitionComponent,
+    OrderRefundTransitionComponent,
+    OrderStateTransitionComponent,
+} from './default-order-history-components.js';
+import { OrderHistoryOrderDetail } from './order-history-types.js';
+import { orderHistoryUtils } from './order-history-utils.js';
 
 interface OrderHistoryProps {
-    order: {
-        id: string;
-        createdAt: string;
-        currencyCode: string;
-        customer?: {
-            firstName: string;
-            lastName: string;
-        } | null;
-    };
+    order: OrderHistoryOrderDetail;
     historyEntries: Array<HistoryEntryItem>;
     onAddNote: (note: string, isPrivate: boolean) => void;
     onUpdateNote?: (entryId: string, note: string, isPrivate: boolean) => void;
@@ -42,22 +33,7 @@ export function OrderHistory({
     onUpdateNote,
     onDeleteNote,
 }: Readonly<OrderHistoryProps>) {
-    const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
-    const [noteEditorOpen, setNoteEditorOpen] = useState(false);
-    const [noteEditorNote, setNoteEditorNote] = useState<{
-        noteId: string;
-        note: string;
-        isPrivate: boolean;
-    }>({
-        noteId: '',
-        note: '',
-        isPrivate: true,
-    });
-
-    const handleEditNote = (noteId: string, note: string, isPrivate: boolean) => {
-        setNoteEditorNote({ noteId, note, isPrivate });
-        setNoteEditorOpen(true);
-    };
+    const { noteState, noteEditorOpen, handleEditNote, setNoteEditorOpen } = useHistoryNoteEditor();
 
     const handleDeleteNote = (noteId: string) => {
         onDeleteNote?.(noteId);
@@ -67,382 +43,62 @@ export function OrderHistory({
         onUpdateNote?.(noteId, note, isPrivate);
     };
 
-    const isPrimaryEvent = (entry: HistoryEntryItem) => {
-        // Based on Angular component's isFeatured method
-        switch (entry.type) {
-            case 'ORDER_STATE_TRANSITION':
-                return (
-                    entry.data.to === 'Delivered' ||
-                    entry.data.to === 'Cancelled' ||
-                    entry.data.to === 'Settled' ||
-                    entry.data.from === 'Created'
-                );
-            case 'ORDER_REFUND_TRANSITION':
-                return entry.data.to === 'Settled';
-            case 'ORDER_PAYMENT_TRANSITION':
-                return entry.data.to === 'Settled' || entry.data.to === 'Cancelled';
-            case 'ORDER_FULFILLMENT_TRANSITION':
-                return entry.data.to === 'Delivered' || entry.data.to === 'Shipped';
-            case 'ORDER_NOTE':
-            case 'ORDER_MODIFIED':
-            case 'ORDER_CUSTOMER_UPDATED':
-            case 'ORDER_CANCELLATION':
-                return true;
-            default:
-                return false; // All other events are secondary
-        }
-    };
+    const { getTimelineIcon, getTitle, getIconColor, getActorName, isPrimaryEvent } =
+        orderHistoryUtils(order);
 
-    // Group consecutive secondary events
-    const groupedEntries: Array<
-        | { type: 'primary'; entry: HistoryEntryItem; index: number }
-        | {
-              type: 'secondary-group';
-              entries: Array<{ entry: HistoryEntryItem; index: number }>;
-              startIndex: number;
-          }
-    > = [];
-    let currentGroup: Array<{ entry: HistoryEntryItem; index: number }> = [];
-
-    for (let i = 0; i < historyEntries.length; i++) {
-        const entry = historyEntries[i];
-        const isSecondary = !isPrimaryEvent(entry);
-
-        if (isSecondary) {
-            currentGroup.push({ entry, index: i });
-        } else {
-            // If we have accumulated secondary events, add them as a group
-            if (currentGroup.length > 0) {
-                groupedEntries.push({
-                    type: 'secondary-group',
-                    entries: currentGroup,
-                    startIndex: currentGroup[0].index,
-                });
-                currentGroup = [];
-            }
-            // Add the primary event
-            groupedEntries.push({ type: 'primary', entry, index: i });
-        }
-    }
-
-    // Don't forget the last group if it exists
-    if (currentGroup.length > 0) {
-        groupedEntries.push({
-            type: 'secondary-group',
-            entries: currentGroup,
-            startIndex: currentGroup[0].index,
-        });
-    }
-
-    const toggleGroup = (groupIndex: number) => {
-        const newExpanded = new Set(expandedGroups);
-        if (newExpanded.has(groupIndex)) {
-            newExpanded.delete(groupIndex);
-        } else {
-            newExpanded.add(groupIndex);
-        }
-        setExpandedGroups(newExpanded);
-    };
-    const getTimelineIcon = (entry: OrderHistoryProps['historyEntries'][0]) => {
-        switch (entry.type) {
-            case 'ORDER_PAYMENT_TRANSITION':
-                return <CreditCardIcon className="h-4 w-4" />;
-            case 'ORDER_REFUND_TRANSITION':
-                return <CreditCardIcon className="h-4 w-4" />;
-            case 'ORDER_NOTE':
-                return <SquarePen className="h-4 w-4" />;
-            case 'ORDER_STATE_TRANSITION':
-                if (entry.data.to === 'Delivered') {
-                    return <CheckIcon className="h-4 w-4" />;
-                }
-                if (entry.data.to === 'Cancelled') {
-                    return <Ban className="h-4 w-4" />;
-                }
-                return <ArrowRightToLine className="h-4 w-4" />;
-            case 'ORDER_FULFILLMENT_TRANSITION':
-                if (entry.data.to === 'Shipped' || entry.data.to === 'Delivered') {
-                    return <Truck className="h-4 w-4" />;
-                }
-                return <ArrowRightToLine className="h-4 w-4" />;
-            case 'ORDER_FULFILLMENT':
-                return <Truck className="h-4 w-4" />;
-            case 'ORDER_MODIFIED':
-                return <Edit3 className="h-4 w-4" />;
-            case 'ORDER_CUSTOMER_UPDATED':
-                return <UserX className="h-4 w-4" />;
-            case 'ORDER_CANCELLATION':
-                return <Ban className="h-4 w-4" />;
-            default:
-                return <CheckIcon className="h-4 w-4" />;
-        }
-    };
-
-    const getTitle = (entry: OrderHistoryProps['historyEntries'][0]) => {
-        switch (entry.type) {
-            case 'ORDER_PAYMENT_TRANSITION':
-                if (entry.data.to === 'Settled') {
-                    return <Trans>Payment settled</Trans>;
-                }
-                if (entry.data.to === 'Authorized') {
-                    return <Trans>Payment authorized</Trans>;
-                }
-                if (entry.data.to === 'Declined' || entry.data.to === 'Cancelled') {
-                    return <Trans>Payment failed</Trans>;
-                }
-                return <Trans>Payment transitioned</Trans>;
-            case 'ORDER_REFUND_TRANSITION':
-                if (entry.data.to === 'Settled') {
-                    return <Trans>Refund settled</Trans>;
-                }
-                return <Trans>Refund transitioned</Trans>;
-            case 'ORDER_NOTE':
-                return <Trans>Note added</Trans>;
-            case 'ORDER_STATE_TRANSITION': {
-                if (entry.data.from === 'Created') {
-                    return <Trans>Order placed</Trans>;
-                }
-                if (entry.data.to === 'Delivered') {
-                    return <Trans>Order fulfilled</Trans>;
-                }
-                if (entry.data.to === 'Cancelled') {
-                    return <Trans>Order cancelled</Trans>;
-                }
-                if (entry.data.to === 'Shipped') {
-                    return <Trans>Order shipped</Trans>;
-                }
-                return <Trans>Order transitioned</Trans>;
-            }
-            case 'ORDER_FULFILLMENT_TRANSITION':
-                if (entry.data.to === 'Shipped') {
-                    return <Trans>Order shipped</Trans>;
-                }
-                if (entry.data.to === 'Delivered') {
-                    return <Trans>Order delivered</Trans>;
-                }
-                return <Trans>Fulfillment transitioned</Trans>;
-            case 'ORDER_FULFILLMENT':
-                return <Trans>Fulfillment created</Trans>;
-            case 'ORDER_MODIFIED':
-                return <Trans>Order modified</Trans>;
-            case 'ORDER_CUSTOMER_UPDATED':
-                return <Trans>Customer updated</Trans>;
-            case 'ORDER_CANCELLATION':
-                return <Trans>Order cancelled</Trans>;
-            default:
-                return <Trans>{entry.type.replace(/_/g, ' ').toLowerCase()}</Trans>;
+    const renderEntryContent = (entry: HistoryEntryItem) => {
+        const props: HistoryEntryProps = {
+            entry,
+            title: getTitle(entry),
+            actorName: getActorName(entry),
+            timelineIcon: getTimelineIcon(entry),
+            timelineIconClassName: getIconColor(entry),
+            isPrimary: isPrimaryEvent(entry),
+            children: null,
+        };
+        if (entry.type === 'ORDER_NOTE') {
+            return (
+                <HistoryNoteEntry {...props} onEditNote={handleEditNote} onDeleteNote={handleDeleteNote} />
+            );
+        } else if (entry.type === 'ORDER_STATE_TRANSITION') {
+            return <OrderStateTransitionComponent {...props} />;
+        } else if (entry.type === 'ORDER_PAYMENT_TRANSITION') {
+            return <OrderPaymentTransitionComponent {...props} />;
+        } else if (entry.type === 'ORDER_REFUND_TRANSITION') {
+            return <OrderRefundTransitionComponent {...props} />;
+        } else if (entry.type === 'ORDER_FULFILLMENT_TRANSITION') {
+            return <OrderFulfillmentTransitionComponent {...props} />;
+        } else if (entry.type === 'ORDER_FULFILLMENT') {
+            return <OrderFulfillmentComponent {...props} />;
+        } else if (entry.type === 'ORDER_MODIFIED') {
+            return <OrderModifiedComponent {...props} />;
+        } else if (entry.type === 'ORDER_CUSTOMER_UPDATED') {
+            return <OrderCustomerUpdatedComponent {...props} />;
+        } else if (entry.type === 'ORDER_CANCELLATION') {
+            return <OrderCancellationComponent {...props} />;
         }
+        return null;
     };
 
     return (
-        <div className="">
-            <div className="mb-4">
+        <>
+            <HistoryTimelineWithGrouping
+                historyEntries={historyEntries}
+                isPrimaryEvent={isPrimaryEvent}
+                renderEntryContent={renderEntryContent}
+                entity={order}
+            >
                 <HistoryNoteInput onAddNote={onAddNote} />
-            </div>
-            <HistoryTimeline>
-                {groupedEntries.map((group, groupIndex) => {
-                    if (group.type === 'primary') {
-                        const entry = group.entry;
-                        return (
-                            <HistoryEntry
-                                key={entry.id}
-                                entry={entry}
-                                isNoteEntry={entry.type === 'ORDER_NOTE'}
-                                timelineIcon={getTimelineIcon(entry)}
-                                title={getTitle(entry)}
-                                isPrimary={true}
-                                customer={order.customer}
-                                onEditNote={handleEditNote}
-                                onDeleteNote={handleDeleteNote}
-                            >
-                                {entry.type === 'ORDER_NOTE' && (
-                                    <div className="space-y-2">
-                                        <p className="text-sm text-foreground">{entry.data.note}</p>
-                                        <div className="flex items-center gap-2">
-                                            <Badge
-                                                variant={entry.isPublic ? 'outline' : 'secondary'}
-                                                className="text-xs"
-                                            >
-                                                {entry.isPublic ? 'Public' : 'Private'}
-                                            </Badge>
-                                        </div>
-                                    </div>
-                                )}
-                                {entry.type === 'ORDER_STATE_TRANSITION' && entry.data.from !== 'Created' && (
-                                    <p className="text-xs text-muted-foreground">
-                                        <Trans>
-                                            From {entry.data.from} to {entry.data.to}
-                                        </Trans>
-                                    </p>
-                                )}
-                                {entry.type === 'ORDER_PAYMENT_TRANSITION' && (
-                                    <p className="text-xs text-muted-foreground">
-                                        <Trans>
-                                            Payment #{entry.data.paymentId} transitioned to {entry.data.to}
-                                        </Trans>
-                                    </p>
-                                )}
-                                {entry.type === 'ORDER_REFUND_TRANSITION' && (
-                                    <p className="text-xs text-muted-foreground">
-                                        <Trans>
-                                            Refund #{entry.data.refundId} transitioned to {entry.data.to}
-                                        </Trans>
-                                    </p>
-                                )}
-                                {entry.type === 'ORDER_FULFILLMENT_TRANSITION' &&
-                                    entry.data.from !== 'Created' && (
-                                        <p className="text-xs text-muted-foreground">
-                                            <Trans>
-                                                Fulfillment #{entry.data.fulfillmentId} from {entry.data.from}{' '}
-                                                to {entry.data.to}
-                                            </Trans>
-                                        </p>
-                                    )}
-                                {entry.type === 'ORDER_FULFILLMENT' && (
-                                    <p className="text-xs text-muted-foreground">
-                                        <Trans>Fulfillment #{entry.data.fulfillmentId} created</Trans>
-                                    </p>
-                                )}
-                                {entry.type === 'ORDER_MODIFIED' && (
-                                    <p className="text-xs text-muted-foreground">
-                                        <Trans>Order modification #{entry.data.modificationId}</Trans>
-                                    </p>
-                                )}
-                                {entry.type === 'ORDER_CUSTOMER_UPDATED' && (
-                                    <p className="text-xs text-muted-foreground">
-                                        <Trans>Customer information updated</Trans>
-                                    </p>
-                                )}
-                                {entry.type === 'ORDER_CANCELLATION' && (
-                                    <p className="text-xs text-muted-foreground">
-                                        <Trans>Order cancelled</Trans>
-                                    </p>
-                                )}
-                            </HistoryEntry>
-                        );
-                    } else {
-                        // Secondary group
-                        const shouldCollapse = group.entries.length > 2;
-                        const isExpanded = expandedGroups.has(groupIndex);
-                        const visibleEntries =
-                            shouldCollapse && !isExpanded ? group.entries.slice(0, 2) : group.entries;
-
-                        return (
-                            <div key={`group-${groupIndex}`}>
-                                {visibleEntries.map(({ entry }) => (
-                                    <HistoryEntry
-                                        key={entry.id}
-                                        entry={entry}
-                                        isNoteEntry={entry.type === 'ORDER_NOTE'}
-                                        timelineIcon={getTimelineIcon(entry)}
-                                        title={getTitle(entry)}
-                                        isPrimary={false}
-                                        customer={order.customer}
-                                        onEditNote={handleEditNote}
-                                        onDeleteNote={handleDeleteNote}
-                                    >
-                                        {entry.type === 'ORDER_NOTE' && (
-                                            <div className="space-y-1">
-                                                <p className="text-xs text-foreground">{entry.data.note}</p>
-                                                <Badge
-                                                    variant={entry.isPublic ? 'outline' : 'secondary'}
-                                                    className="text-xs"
-                                                >
-                                                    {entry.isPublic ? 'Public' : 'Private'}
-                                                </Badge>
-                                            </div>
-                                        )}
-                                        {entry.type === 'ORDER_STATE_TRANSITION' &&
-                                            entry.data.from !== 'Created' && (
-                                                <p className="text-xs text-muted-foreground">
-                                                    <Trans>
-                                                        From {entry.data.from} to {entry.data.to}
-                                                    </Trans>
-                                                </p>
-                                            )}
-                                        {entry.type === 'ORDER_PAYMENT_TRANSITION' && (
-                                            <p className="text-xs text-muted-foreground">
-                                                <Trans>
-                                                    Payment #{entry.data.paymentId} transitioned to{' '}
-                                                    {entry.data.to}
-                                                </Trans>
-                                            </p>
-                                        )}
-                                        {entry.type === 'ORDER_REFUND_TRANSITION' && (
-                                            <p className="text-xs text-muted-foreground">
-                                                <Trans>
-                                                    Refund #{entry.data.refundId} transitioned to{' '}
-                                                    {entry.data.to}
-                                                </Trans>
-                                            </p>
-                                        )}
-                                        {entry.type === 'ORDER_FULFILLMENT_TRANSITION' &&
-                                            entry.data.from !== 'Created' && (
-                                                <p className="text-xs text-muted-foreground">
-                                                    <Trans>
-                                                        Fulfillment #{entry.data.fulfillmentId} from{' '}
-                                                        {entry.data.from} to {entry.data.to}
-                                                    </Trans>
-                                                </p>
-                                            )}
-                                        {entry.type === 'ORDER_FULFILLMENT' && (
-                                            <p className="text-xs text-muted-foreground">
-                                                <Trans>Fulfillment #{entry.data.fulfillmentId} created</Trans>
-                                            </p>
-                                        )}
-                                        {entry.type === 'ORDER_MODIFIED' && (
-                                            <p className="text-xs text-muted-foreground">
-                                                <Trans>Order modification #{entry.data.modificationId}</Trans>
-                                            </p>
-                                        )}
-                                        {entry.type === 'ORDER_CUSTOMER_UPDATED' && (
-                                            <p className="text-xs text-muted-foreground">
-                                                <Trans>Customer information updated</Trans>
-                                            </p>
-                                        )}
-                                        {entry.type === 'ORDER_CANCELLATION' && (
-                                            <p className="text-xs text-muted-foreground">
-                                                <Trans>Order cancelled</Trans>
-                                            </p>
-                                        )}
-                                    </HistoryEntry>
-                                ))}
-
-                                {shouldCollapse && (
-                                    <div className="flex justify-center py-2">
-                                        <Button
-                                            variant="ghost"
-                                            size="sm"
-                                            onClick={() => toggleGroup(groupIndex)}
-                                            className="text-muted-foreground hover:text-foreground h-6 text-xs"
-                                        >
-                                            {isExpanded ? (
-                                                <>
-                                                    <ChevronUp className="w-3 h-3 mr-1" />
-                                                    <Trans>Show less</Trans>
-                                                </>
-                                            ) : (
-                                                <>
-                                                    <ChevronDown className="w-3 h-3 mr-1" />
-                                                    <Trans>Show all ({group.entries.length})</Trans>
-                                                </>
-                                            )}
-                                        </Button>
-                                    </div>
-                                )}
-                            </div>
-                        );
-                    }
-                })}
-            </HistoryTimeline>
+            </HistoryTimelineWithGrouping>
             <HistoryNoteEditor
-                key={noteEditorNote.noteId}
-                note={noteEditorNote.note}
+                key={noteState.noteId}
+                note={noteState.note}
                 onNoteChange={handleNoteEditorSave}
                 open={noteEditorOpen}
                 onOpenChange={setNoteEditorOpen}
-                noteId={noteEditorNote.noteId}
-                isPrivate={noteEditorNote.isPrivate}
+                noteId={noteState.noteId}
+                isPrivate={noteState.isPrivate}
             />
-        </div>
+        </>
     );
 }

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

@@ -13,6 +13,22 @@ export function OrderTableTotals({ order, columnCount }: Readonly<OrderTableTota
 
     return (
         <>
+            {order.surcharges?.length > 0
+                ? order.surcharges.map((surcharge, index) => (
+                      <TableRow key={`${surcharge.description}-${index}`}>
+                          <TableCell colSpan={columnCount - 1} className="h-12">
+                              <Trans>Surcharge</Trans>: {surcharge.description}
+                          </TableCell>
+                          <TableCell colSpan={1} className="h-12">
+                              <MoneyGrossNet
+                                  priceWithTax={surcharge.priceWithTax}
+                                  price={surcharge.price}
+                                  currencyCode={currencyCode}
+                              />
+                          </TableCell>
+                      </TableRow>
+                  ))
+                : null}
             {order.discounts?.length > 0
                 ? order.discounts.map((discount, index) => (
                       <TableRow key={`${discount.description}-${index}`}>

+ 61 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/seller-orders-card.tsx

@@ -0,0 +1,61 @@
+import { DetailPageButton } from '@/vdb/components/shared/detail-page-button.js';
+import { Badge } from '@/vdb/components/ui/badge.js';
+import { api } from '@/vdb/graphql/api.js';
+import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
+import { useQuery } from '@tanstack/react-query';
+import { sellerOrdersDocument } from '../orders.graphql.js';
+import { getSeller } from '../utils/order-utils.js';
+
+export interface SellerOrdersCardProps {
+    orderId: string;
+}
+
+export function SellerOrdersCard({ orderId }: Readonly<SellerOrdersCardProps>) {
+    const { formatCurrency } = useLocalFormat();
+    const { data, isLoading, error } = useQuery({
+        queryKey: ['seller-orders', orderId],
+        queryFn: () => api.query(sellerOrdersDocument, { orderId }),
+    });
+
+    if (isLoading) {
+        return (
+            <div className="animate-pulse space-y-2">
+                {Array.from({ length: 2 }).map((_, i) => (
+                    <div key={i} className="h-16 bg-muted rounded-md" />
+                ))}
+            </div>
+        );
+    }
+
+    if (error || !data?.order || !data.order.sellerOrders?.length) {
+        return null;
+    }
+
+    return (
+        <div className="flex flex-col gap-4 divide-y">
+            {data.order.sellerOrders.map(sellerOrder => {
+                const seller = getSeller(sellerOrder);
+
+                return (
+                    <div key={sellerOrder.id} className="p-3 -mx-3 pb-6 transition-colors">
+                        <div className="flex justify-between items-center">
+                            <DetailPageButton
+                                label={sellerOrder.code}
+                                href={`/orders/${orderId}/seller-orders/${sellerOrder.id}`}
+                            />
+                            <div className="text-sm font-medium">
+                                {formatCurrency(sellerOrder.totalWithTax, sellerOrder.currencyCode)}
+                            </div>
+                        </div>
+                        <div className="flex justify-between mt-1">
+                            <div className="flex gap-2">
+                                {seller && <Badge variant={'secondary'}>{seller.name}</Badge>}
+                            </div>
+                            <Badge variant={'secondary'}>{sellerOrder.state}</Badge>
+                        </div>
+                    </div>
+                );
+            })}
+        </div>
+    );
+}

+ 17 - 10
packages/dashboard/src/app/routes/_authenticated/_orders/components/use-transition-order-to-state.tsx

@@ -1,13 +1,20 @@
+import { Alert, AlertDescription } from '@/vdb/components/ui/alert.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    Dialog,
+    DialogContent,
+    DialogDescription,
+    DialogFooter,
+    DialogHeader,
+    DialogTitle,
+} from '@/vdb/components/ui/dialog.js';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/vdb/components/ui/select.js';
 import { api } from '@/vdb/graphql/api.js';
-import { useMutation, useQuery } from '@tanstack/react-query';
-import { orderHistoryDocument, transitionOrderToStateDocument } from '../orders.graphql.js';
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/vdb/components/ui/dialog.js';
 import { Trans } from '@/vdb/lib/trans.js';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/vdb/components/ui/select.js';
-import { useState } from 'react';
-import { Button } from '@/vdb/components/ui/button.js';
-import { Alert, AlertDescription } from '@/vdb/components/ui/alert.js';
+import { useMutation, useQuery } from '@tanstack/react-query';
 import { ResultOf } from 'gql.tada';
+import { useState } from 'react';
+import { orderHistoryDocument, transitionOrderToStateDocument } from '../orders.graphql.js';
 
 /**
  * Returns the state the order was in before it entered 'Modifying'.
@@ -29,7 +36,7 @@ export function useTransitionOrderToState(orderId: string | undefined) {
             });
             const items = result.order?.history?.items ?? [];
             const modifyingEntry = items.find(i => i.data?.to === 'Modifying');
-            return modifyingEntry ? (modifyingEntry.data?.from as string | undefined) : undefined;
+            return modifyingEntry ? (modifyingEntry.data?.from as string | undefined) : '';
         },
         enabled: !!orderId,
     });
@@ -48,7 +55,7 @@ export function useTransitionOrderToState(orderId: string | undefined) {
             }
         }
         return undefined;
-    }
+    };
 
     const transitionToPreModifyingState = async () => {
         if (data && orderId) {
@@ -70,7 +77,7 @@ export function useTransitionOrderToState(orderId: string | undefined) {
                     onSuccessFn?.();
                 }
             },
-            onError: (error) => {
+            onError: error => {
                 setTransitionError(error.message);
             },
         });

+ 35 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/orders.graphql.ts

@@ -189,6 +189,13 @@ export const orderDetailFragment = graphql(
                     code
                 }
             }
+            channels {
+                id
+                code
+                seller {
+                    name
+                }
+            }
             code
             state
             nextStates
@@ -308,6 +315,10 @@ export const orderHistoryDocument = graphql(`
             updatedAt
             code
             currencyCode
+            customer {
+                firstName
+                lastName
+            }
             history(options: $options) {
                 totalItems
                 items {
@@ -722,3 +733,27 @@ export const setOrderCustomFieldsDocument = graphql(`
         }
     }
 `);
+
+export const sellerOrdersDocument = graphql(`
+    query GetSellerOrders($orderId: ID!) {
+        order(id: $orderId) {
+            id
+            sellerOrders {
+                id
+                code
+                state
+                orderPlacedAt
+                currencyCode
+                totalWithTax
+                channels {
+                    id
+                    code
+                    seller {
+                        id
+                        name
+                    }
+                }
+            }
+        }
+    }
+`);

+ 50 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/orders_.$aggregateOrderId_.seller-orders.$sellerOrderId.tsx

@@ -0,0 +1,50 @@
+import { ErrorPage } from '@/vdb/components/shared/error-page.js';
+import { Badge } from '@/vdb/components/ui/badge.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { createFileRoute, Link } from '@tanstack/react-router';
+import { ArrowLeft } from 'lucide-react';
+import { OrderDetail, OrderDetailShared } from './components/order-detail-shared.js';
+import { loadSellerOrder } from './utils/order-detail-loaders.js';
+import { getSeller } from './utils/order-utils.js';
+
+export const Route = createFileRoute(
+    '/_authenticated/_orders/orders_/$aggregateOrderId_/seller-orders/$sellerOrderId',
+)({
+    component: SellerOrderDetailPage,
+    loader: ({ context, params }) => loadSellerOrder(context, params),
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+function SellerOrderDetailPage() {
+    const params = Route.useParams();
+
+    const titleSlot = (order: OrderDetail) => {
+        const seller = getSeller(order);
+        return (
+            <div className="flex items-center gap-2">
+                <Button variant="ghost" size="sm" asChild>
+                    <Link
+                        to="/orders/$aggregateOrderId"
+                        params={{ aggregateOrderId: params.aggregateOrderId }}
+                    >
+                        <ArrowLeft className="h-4 w-4" />
+                    </Link>
+                </Button>
+                {order.code ?? ''}
+                <Badge variant="secondary">
+                    <Trans>Seller order</Trans>
+                </Badge>
+                {seller && <Badge variant="outline">{seller.name}</Badge>}
+            </div>
+        );
+    };
+
+    return (
+        <OrderDetailShared
+            pageId="seller-order-detail"
+            orderId={params.sellerOrderId}
+            titleSlot={titleSlot}
+        />
+    );
+}

+ 17 - 290
packages/dashboard/src/app/routes/_authenticated/_orders/orders_.$id.tsx

@@ -1,303 +1,30 @@
-import { CustomFieldsForm } from '@/vdb/components/shared/custom-fields-form.js';
 import { ErrorPage } from '@/vdb/components/shared/error-page.js';
-import { PermissionGuard } from '@/vdb/components/shared/permission-guard.js';
-import { Button } from '@/vdb/components/ui/button.js';
-import { DropdownMenuItem } from '@/vdb/components/ui/dropdown-menu.js';
-import { addCustomFields } from '@/vdb/framework/document-introspection/add-custom-fields.js';
-import {
-    Page,
-    PageActionBar,
-    PageActionBarRight,
-    PageBlock,
-    PageLayout,
-    PageTitle,
-} from '@/vdb/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
-import { api } from '@/vdb/graphql/api.js';
-import { ResultOf } from '@/vdb/graphql/graphql.js';
-import { useCustomFieldConfig } from '@/vdb/hooks/use-custom-field-config.js';
-import { Trans, useLingui } from '@/vdb/lib/trans.js';
-import { useMutation, useQueryClient } from '@tanstack/react-query';
-import { createFileRoute, Link, redirect, useNavigate } from '@tanstack/react-router';
-import { Pencil, User } from 'lucide-react';
-import { useMemo } from 'react';
-import { toast } from 'sonner';
-import { AddManualPaymentDialog } from './components/add-manual-payment-dialog.js';
-import { FulfillOrderDialog } from './components/fulfill-order-dialog.js';
-import { FulfillmentDetails } from './components/fulfillment-details.js';
-import { OrderAddress } from './components/order-address.js';
-import { OrderHistoryContainer } from './components/order-history/order-history-container.js';
-import { orderHistoryQueryKey } from './components/order-history/use-order-history.js';
-import { OrderTable } from './components/order-table.js';
-import { OrderTaxSummary } from './components/order-tax-summary.js';
-import { PaymentDetails } from './components/payment-details.js';
-import { getTypeForState, StateTransitionControl } from './components/state-transition-control.js';
-import { useTransitionOrderToState } from './components/use-transition-order-to-state.js';
-import {
-    orderDetailDocument,
-    setOrderCustomFieldsDocument,
-    transitionOrderToStateDocument,
-} from './orders.graphql.js';
-import { canAddFulfillment, shouldShowAddManualPaymentButton } from './utils/order-utils.js';
-
-const pageId = 'order-detail';
+import { PageBlock } from '@/vdb/framework/layout-engine/page-layout.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { createFileRoute } from '@tanstack/react-router';
+import { OrderDetailShared } from './components/order-detail-shared.js';
+import { SellerOrdersCard } from './components/seller-orders-card.js';
+import { loadRegularOrder } from './utils/order-detail-loaders.js';
 
 export const Route = createFileRoute('/_authenticated/_orders/orders_/$id')({
     component: OrderDetailPage,
-    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}`,
-            });
-        }
-
-        if (result.order.state === 'Modifying') {
-            throw redirect({
-                to: `/orders/${params.id}/modify`,
-            });
-        }
-
-        return {
-            breadcrumb: [{ path: '/orders', label: <Trans>Orders</Trans> }, result.order.code],
-        };
-    },
+    loader: ({ context, params }) => loadRegularOrder(context, params),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
 function OrderDetailPage() {
     const params = Route.useParams();
-    const { i18n } = useLingui();
-    const navigate = useNavigate();
-    const queryClient = useQueryClient();
-    const { form, submitHandler, entity, refreshEntity } = useDetailPage({
-        pageId,
-        queryDocument: orderDetailDocument,
-        updateDocument: setOrderCustomFieldsDocument,
-        setValuesForUpdate: (entity: any) => {
-            return {
-                id: entity.id,
-                customFields: entity.customFields,
-            };
-        },
-        params: { id: params.id },
-        onSuccess: async () => {
-            toast(i18n.t('Successfully updated order'));
-            form.reset(form.getValues());
-        },
-        onError: err => {
-            toast(i18n.t('Failed to update order'), {
-                description: err instanceof Error ? err.message : 'Unknown error',
-            });
-        },
-    });
-    const { transitionToState } = useTransitionOrderToState(entity?.id);
-    const transitionOrderToStateMutation = useMutation({
-        mutationFn: api.mutate(transitionOrderToStateDocument),
-    });
-    const customFieldConfig = useCustomFieldConfig('Order');
-    const stateTransitionActions = useMemo(() => {
-        if (!entity) {
-            return [];
-        }
-        return entity.nextStates.map(state => ({
-            label: `Transition to ${state}`,
-            type: getTypeForState(state),
-            onClick: async () => {
-                const transitionError = await transitionToState(state);
-                if (transitionError) {
-                    toast(i18n.t('Failed to transition order to state'), {
-                        description: transitionError,
-                    });
-                } else {
-                    refreshOrderAndHistory();
-                }
-            },
-        }));
-    }, [entity, transitionToState, i18n]);
-
-    if (!entity) {
-        return null;
-    }
-
-    const handleModifyClick = async () => {
-        try {
-            await transitionOrderToStateMutation.mutateAsync({
-                id: entity.id,
-                state: 'Modifying',
-            });
-            const queryKey = getDetailQueryOptions(orderDetailDocument, { id: entity.id }).queryKey;
-            await queryClient.invalidateQueries({ queryKey });
-            await navigate({ to: `/orders/$id/modify`, params: { id: entity.id } });
-        } catch (error) {
-            toast(i18n.t('Failed to modify order'), {
-                description: error instanceof Error ? error.message : 'Unknown error',
-            });
-        }
-    };
-
-    const nextStates = entity.nextStates;
-    const showAddPaymentButton = shouldShowAddManualPaymentButton(entity);
-    const showFulfillButton = canAddFulfillment(entity);
-
-    async function refreshOrderAndHistory() {
-        if (entity) {
-            const queryKey = getDetailQueryOptions(orderDetailDocument, { id: entity.id }).queryKey;
-            await queryClient.invalidateQueries({ queryKey });
-            queryClient.refetchQueries({ queryKey: orderHistoryQueryKey(entity.id) });
-        }
-    }
-
     return (
-        <Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
-            <PageTitle>{entity?.code ?? ''}</PageTitle>
-            <PageActionBar>
-                <PageActionBarRight
-                    dropdownMenuItems={[
-                        ...(nextStates.includes('Modifying')
-                            ? [
-                                  {
-                                      component: () => (
-                                          <DropdownMenuItem onClick={handleModifyClick}>
-                                              <Pencil className="w-4 h-4" />
-                                              <Trans>Modify</Trans>
-                                          </DropdownMenuItem>
-                                      ),
-                                  },
-                              ]
-                            : []),
-                    ]}
-                >
-                    {showAddPaymentButton && (
-                        <PermissionGuard requires={['UpdateOrder']}>
-                            <AddManualPaymentDialog
-                                order={entity}
-                                onSuccess={() => {
-                                    refreshEntity();
-                                }}
-                            />
-                        </PermissionGuard>
-                    )}
-                    {showFulfillButton && (
-                        <PermissionGuard requires={['UpdateOrder']}>
-                            <FulfillOrderDialog
-                                order={entity}
-                                onSuccess={() => {
-                                    refreshOrderAndHistory();
-                                }}
-                            />
-                        </PermissionGuard>
-                    )}
-                </PageActionBarRight>
-            </PageActionBar>
-            <PageLayout>
-                <PageBlock column="main" blockId="order-table">
-                    <OrderTable order={entity} pageId={pageId} />
-                </PageBlock>
-                <PageBlock column="main" blockId="tax-summary" title={<Trans>Tax summary</Trans>}>
-                    <OrderTaxSummary order={entity} />
-                </PageBlock>
-                {customFieldConfig?.length ? (
-                    <PageBlock column="main" blockId="custom-fields">
-                        <CustomFieldsForm entityType="Order" control={form.control} />
-                        <div className="flex justify-end">
-                            <Button
-                                type="submit"
-                                disabled={!form.formState.isDirty || !form.formState.isValid}
-                            >
-                                Save
-                            </Button>
-                        </div>
+        <OrderDetailShared
+            pageId="order-detail"
+            orderId={params.id}
+            beforeOrderTable={order =>
+                order.sellerOrders?.length ? (
+                    <PageBlock column="main" blockId="seller-orders" title={<Trans>Seller orders</Trans>}>
+                        <SellerOrdersCard orderId={params.id} />
                     </PageBlock>
-                ) : null}
-                <PageBlock column="main" blockId="payment-details" title={<Trans>Payment details</Trans>}>
-                    <div className="grid lg:grid-cols-2 gap-4">
-                        {entity?.payments?.map(payment => (
-                            <PaymentDetails
-                                key={payment.id}
-                                payment={payment}
-                                currencyCode={entity.currencyCode}
-                                onSuccess={() => refreshOrderAndHistory()}
-                            />
-                        ))}
-                    </div>
-                </PageBlock>
-                <PageBlock column="main" blockId="order-history" title={<Trans>Order history</Trans>}>
-                    <OrderHistoryContainer orderId={entity.id} />
-                </PageBlock>
-                <PageBlock column="side" blockId="state">
-                    <StateTransitionControl
-                        currentState={entity?.state}
-                        actions={stateTransitionActions}
-                        isLoading={transitionOrderToStateMutation.isPending}
-                    />
-                </PageBlock>
-                <PageBlock column="side" blockId="customer" title={<Trans>Customer</Trans>}>
-                    <Button variant="ghost" asChild>
-                        <Link to={`/customers/${entity?.customer?.id}`}>
-                            <User className="w-4 h-4" />
-                            {entity?.customer?.firstName} {entity?.customer?.lastName}
-                        </Link>
-                    </Button>
-                    <div className="mt-4 divide-y">
-                        {entity?.shippingAddress && (
-                            <div className="pb-6">
-                                <div className="font-medium">
-                                    <Trans>Shipping address</Trans>
-                                </div>
-                                <OrderAddress address={entity.shippingAddress} />
-                            </div>
-                        )}
-                        {entity?.billingAddress && (
-                            <div className="pt-4">
-                                <div className="font-medium">
-                                    <Trans>Billing address</Trans>
-                                </div>
-                                <OrderAddress address={entity.billingAddress} />
-                            </div>
-                        )}
-                    </div>
-                </PageBlock>
-                <PageBlock
-                    column="side"
-                    blockId="fulfillment-details"
-                    title={<Trans>Fulfillment details</Trans>}
-                >
-                    {entity?.fulfillments?.length && entity.fulfillments.length > 0 ? (
-                        <div className="space-y-2">
-                            {entity?.fulfillments?.map(fulfillment => (
-                                <FulfillmentDetails
-                                    key={fulfillment.id}
-                                    order={entity}
-                                    fulfillment={fulfillment}
-                                    onSuccess={() => {
-                                        refreshEntity();
-                                        queryClient.refetchQueries({
-                                            queryKey: orderHistoryQueryKey(entity.id),
-                                        });
-                                    }}
-                                />
-                            ))}
-                        </div>
-                    ) : (
-                        <div className="text-muted-foreground text-xs font-medium p-3 border rounded-md">
-                            <Trans>No fulfillments</Trans>
-                        </div>
-                    )}
-                </PageBlock>
-            </PageLayout>
-        </Page>
+                ) : undefined
+            }
+        />
     );
 }

+ 7 - 39
packages/dashboard/src/app/routes/_authenticated/_orders/orders_.$id_.modify.tsx

@@ -1,6 +1,5 @@
 import { ErrorPage } from '@/vdb/components/shared/error-page.js';
 import { Button } from '@/vdb/components/ui/button.js';
-import { addCustomFields } from '@/vdb/framework/document-introspection/add-custom-fields.js';
 import {
     Page,
     PageActionBar,
@@ -13,8 +12,8 @@ import { getDetailQueryOptions, useDetailPage } from '@/vdb/framework/page/use-d
 import { api } from '@/vdb/graphql/api.js';
 import { Trans, useLingui } from '@/vdb/lib/trans.js';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
-import { createFileRoute, Link, redirect, useNavigate } from '@tanstack/react-router';
-import { ResultOf, VariablesOf } from 'gql.tada';
+import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
+import { VariablesOf } from 'gql.tada';
 import { User } from 'lucide-react';
 import { useEffect, useState } from 'react';
 import { toast } from 'sonner';
@@ -29,6 +28,7 @@ import {
     modifyOrderDocument,
     orderDetailDocument,
 } from './orders.graphql.js';
+import { loadModifyingOrder } from './utils/order-detail-loaders.js';
 import { AddressFragment, Order } from './utils/order-types.js';
 
 const pageId = 'order-modify';
@@ -36,39 +36,7 @@ type ModifyOrderInput = VariablesOf<typeof modifyOrderDocument>['input'];
 
 export const Route = createFileRoute('/_authenticated/_orders/orders_/$id_/modify')({
     component: ModifyOrderPage,
-    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}`,
-            });
-        }
-        if (result.order.state !== 'Modifying') {
-            throw redirect({
-                to: `/orders/${params.id}`,
-            });
-        }
-
-        return {
-            breadcrumb: [
-                { path: '/orders', label: <Trans>Orders</Trans> },
-                result.order.code,
-                { label: <Trans>Modify</Trans> },
-            ],
-        };
-    },
+    loader: async ({ context, params }) => loadModifyingOrder(context, params),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
@@ -385,10 +353,10 @@ function ModifyOrderPage() {
     }
 
     // On successful state transition, invalidate the order detail query and navigate to the order detail page
-    const onSuccess = () => {
+    const onSuccess = async () => {
         const queryKey = getDetailQueryOptions(orderDetailDocument, { id: entity.id }).queryKey;
-        queryClient.invalidateQueries({ queryKey });
-        navigate({ to: `/orders/$id`, params: { id: entity?.id } });
+        await queryClient.invalidateQueries({ queryKey });
+        await navigate({ to: `/orders/$id`, params: { id: entity?.id } });
     };
 
     const handleCancelModificationClick = async () => {

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

@@ -15,11 +15,11 @@ import {
     PageLayout,
     PageTitle,
 } from '@/vdb/framework/layout-engine/page-layout.js';
-import { getDetailQueryOptions, useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
+import { useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
 import { api } from '@/vdb/graphql/api.js';
 import { Trans, useLingui } from '@/vdb/lib/trans.js';
 import { useMutation, useQuery } from '@tanstack/react-query';
-import { createFileRoute, Link, redirect, useNavigate } from '@tanstack/react-router';
+import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
 import { ResultOf } from 'gql.tada';
 import { User } from 'lucide-react';
 import { toast } from 'sonner';
@@ -44,33 +44,11 @@ import {
     unsetBillingAddressForDraftOrderDocument,
     unsetShippingAddressForDraftOrderDocument,
 } from './orders.graphql.js';
+import { loadDraftOrder } from './utils/order-detail-loaders.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: <Trans>Orders</Trans> }, result.order.code],
-        };
-    },
+    loader: ({ context, params }) => loadDraftOrder(context, params),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 

+ 134 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/utils/order-detail-loaders.tsx

@@ -0,0 +1,134 @@
+import { addCustomFields } from '@/vdb/framework/document-introspection/add-custom-fields.js';
+import { getDetailQueryOptions } from '@/vdb/framework/page/use-detail-page.js';
+import { ResultOf } from '@/vdb/graphql/graphql.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { redirect } from '@tanstack/react-router';
+import { OrderDetail } from '../components/order-detail-shared.js';
+import { orderDetailDocument } from '../orders.graphql.js';
+
+async function ensureOrderWithIdExists(context: any, params: { id: string }): Promise<OrderDetail> {
+    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 }),
+    );
+
+    if (!result.order) {
+        throw new Error(`Order with the ID ${params.id} was not found`);
+    }
+    return result.order;
+}
+
+export async function commonRegularOrderLoader(context: any, params: { id: string }): Promise<OrderDetail> {
+    const order = await ensureOrderWithIdExists(context, params);
+
+    if (order.state === 'Draft') {
+        throw redirect({
+            to: `/orders/draft/${params.id}`,
+        });
+    }
+    return order;
+}
+
+export async function loadRegularOrder(context: any, params: { id: string }) {
+    const order = await commonRegularOrderLoader(context, params);
+
+    if (order.state === 'Modifying') {
+        throw redirect({
+            to: `/orders/${params.id}/modify`,
+        });
+    }
+
+    return {
+        breadcrumb: [{ path: '/orders', label: <Trans>Orders</Trans> }, order.code],
+    };
+}
+
+export async function loadDraftOrder(context: any, params: { id: string }) {
+    const order = await ensureOrderWithIdExists(context, params);
+
+    if (order.state !== 'Draft') {
+        throw redirect({
+            to: `/orders/${params.id}`,
+        });
+    }
+
+    return {
+        breadcrumb: [{ path: '/orders', label: <Trans>Orders</Trans> }, order.code],
+    };
+}
+
+export async function loadModifyingOrder(context: any, params: { id: string }) {
+    const order = await commonRegularOrderLoader(context, params);
+    if (order.state !== 'Modifying') {
+        throw redirect({
+            to: `/orders/${params.id}`,
+        });
+    }
+
+    return {
+        breadcrumb: [
+            { path: '/orders', label: <Trans>Orders</Trans> },
+            order.code,
+            { label: <Trans>Modify</Trans> },
+        ],
+    };
+}
+
+export async function loadSellerOrder(
+    context: any,
+    params: { aggregateOrderId: string; sellerOrderId: string },
+) {
+    if (!params.sellerOrderId || !params.aggregateOrderId) {
+        throw new Error('Both seller order ID and aggregate order ID params are required');
+    }
+
+    const result: ResultOf<typeof orderDetailDocument> = await context.queryClient.ensureQueryData(
+        getDetailQueryOptions(addCustomFields(orderDetailDocument), { id: params.sellerOrderId }),
+    );
+
+    if (!result.order) {
+        throw new Error(`Seller order with the ID ${params.sellerOrderId} was not found`);
+    }
+
+    // Verify this is actually a seller order by checking if it has an aggregateOrder
+    if (!result.order.aggregateOrder) {
+        throw new Error(`Order ${params.sellerOrderId} is not a seller order`);
+    }
+
+    // Verify the aggregate order ID matches
+    if (result.order.aggregateOrder.id !== params.aggregateOrderId) {
+        throw new Error(
+            `Seller order ${params.sellerOrderId} does not belong to aggregate order ${params.aggregateOrderId}`,
+        );
+    }
+
+    if (result.order.state === 'Draft') {
+        throw redirect({
+            to: `/orders/draft/${params.sellerOrderId}`,
+        });
+    }
+
+    if (result.order.state === 'Modifying') {
+        throw redirect({
+            to: `/orders/${params.sellerOrderId}/modify`,
+        });
+    }
+
+    return {
+        breadcrumb: [
+            { path: '/orders', label: <Trans>Orders</Trans> },
+            {
+                path: `/orders/${params.aggregateOrderId}`,
+                label: result.order.aggregateOrder.code,
+            },
+            {
+                path: `/orders/${params.aggregateOrderId}`,
+                label: 'Seller orders',
+            },
+            result.order.code,
+        ],
+    };
+}

+ 8 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/utils/order-utils.ts

@@ -1,3 +1,5 @@
+import { DEFAULT_CHANNEL_CODE } from '@/vdb/constants.js';
+
 import { Fulfillment, Order, Payment } from './order-types.js';
 
 /**
@@ -76,3 +78,9 @@ export function canAddFulfillment(order: Order): boolean {
         isFulfillableState
     );
 }
+
+export function getSeller<T>(order: { channels: Array<{ code: string; seller: T }> }) {
+    // Find the seller channel (non-default channel)
+    const sellerChannel = order.channels.find(channel => channel.code !== DEFAULT_CHANNEL_CODE);
+    return sellerChannel?.seller;
+}

+ 0 - 120
packages/dashboard/src/app/routes/_authenticated/_roles/components/permissions-grid.tsx

@@ -1,120 +0,0 @@
-import {
-    Accordion,
-    AccordionContent,
-    AccordionItem,
-    AccordionTrigger,
-} from '@/vdb/components/ui/accordion.js';
-import { Button } from '@/vdb/components/ui/button.js';
-import { Switch } from '@/vdb/components/ui/switch.js';
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/vdb/components/ui/tooltip.js';
-import { useGroupedPermissions } from '@/vdb/hooks/use-grouped-permissions.js';
-import { Trans, useLingui } from '@/vdb/lib/trans.js';
-import { ServerConfig } from '@/vdb/providers/server-config.js';
-import { useState } from 'react';
-
-interface PermissionsGridProps {
-    value: string[];
-    onChange: (permissions: string[]) => void;
-    readonly?: boolean;
-}
-
-export function PermissionsGrid({ value, onChange, readonly = false }: Readonly<PermissionsGridProps>) {
-    const { i18n } = useLingui();
-    const groupedPermissions = useGroupedPermissions();
-
-    const setPermission = (permission: string, checked: boolean) => {
-        if (readonly) return;
-
-        const newPermissions = checked ? [...value, permission] : value.filter(p => p !== permission);
-        onChange(newPermissions);
-    };
-
-    const toggleAll = (defs: ServerConfig['permissions']) => {
-        if (readonly) return;
-
-        const shouldEnable = defs.some(d => !value.includes(d.name));
-        const newPermissions = shouldEnable
-            ? [...new Set([...value, ...defs.map(d => d.name)])]
-            : value.filter(p => !defs.some(d => d.name === p));
-        onChange(newPermissions);
-    };
-
-    // Get default expanded sections based on which ones have active permissions
-    const defaultExpandedSections = groupedPermissions
-        .map(section => ({
-            section,
-            hasActivePermissions: section.permissions.some(permission => value.includes(permission.name)),
-        }))
-        .filter(({ hasActivePermissions }) => hasActivePermissions)
-        .map(({ section }) => section.id);
-
-    const [accordionValue, setAccordionValue] = useState<string[]>(defaultExpandedSections);
-
-    return (
-        <div className="w-full">
-            <Accordion
-                type="multiple"
-                value={accordionValue.length ? accordionValue : defaultExpandedSections}
-                onValueChange={setAccordionValue}
-                className="space-y-4"
-            >
-                {groupedPermissions.map((section, index) => (
-                    <AccordionItem key={index} value={section.id} className="border rounded-lg px-6">
-                        <AccordionTrigger className="hover:no-underline">
-                            <div className="flex flex-col items-start gap-1 text-sm py-2">
-                                <div>{i18n.t(section.label)}</div>
-                                <div className="text-muted-foreground text-sm font-normal">
-                                    {i18n.t(section.description)}
-                                </div>
-                            </div>
-                        </AccordionTrigger>
-                        <AccordionContent>
-                            <div className="pb-4 space-y-4">
-                                {section.permissions.length > 1 && !readonly && (
-                                    <Button
-                                        variant="outline"
-                                        type="button"
-                                        size="sm"
-                                        onClick={() => toggleAll(section.permissions)}
-                                        className="w-fit"
-                                    >
-                                        <Trans>Toggle all</Trans>
-                                    </Button>
-                                )}
-                                <div className="md:grid md:grid-cols-4 md:gap-2 space-y-2">
-                                    {section.permissions.map(permission => (
-                                        <div key={permission.name} className="flex items-center space-x-2">
-                                            <Switch
-                                                id={permission.name}
-                                                checked={value.includes(permission.name)}
-                                                onCheckedChange={checked =>
-                                                    setPermission(permission.name, checked)
-                                                }
-                                                disabled={readonly}
-                                            />
-                                            <TooltipProvider>
-                                                <Tooltip>
-                                                    <TooltipTrigger asChild>
-                                                        <label
-                                                            htmlFor={permission.name}
-                                                            className="text-sm whitespace-nowrap"
-                                                        >
-                                                            {i18n.t(permission.name)}
-                                                        </label>
-                                                    </TooltipTrigger>
-                                                    <TooltipContent align="end">
-                                                        <p>{i18n.t(permission.description)}</p>
-                                                    </TooltipContent>
-                                                </Tooltip>
-                                            </TooltipProvider>
-                                        </div>
-                                    ))}
-                                </div>
-                            </div>
-                        </AccordionContent>
-                    </AccordionItem>
-                ))}
-            </Accordion>
-        </div>
-    );
-}

+ 251 - 0
packages/dashboard/src/app/routes/_authenticated/_roles/components/permissions-table-grid.tsx

@@ -0,0 +1,251 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import { Switch } from '@/vdb/components/ui/switch.js';
+import { Table, TableBody, TableCell, TableRow } from '@/vdb/components/ui/table.js';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/vdb/components/ui/tooltip.js';
+import { useGroupedPermissions } from '@/vdb/hooks/use-grouped-permissions.js';
+import { Trans, useLingui } from '@/vdb/lib/trans.js';
+import { ServerConfig } from '@/vdb/providers/server-config.js';
+import { InfoIcon } from 'lucide-react';
+
+interface PermissionsTableGridProps {
+    value: string[];
+    onChange: (permissions: string[]) => void;
+    readonly?: boolean;
+}
+
+export function PermissionsTableGrid({
+    value,
+    onChange,
+    readonly = false,
+}: Readonly<PermissionsTableGridProps>) {
+    const { i18n } = useLingui();
+    const groupedPermissions = useGroupedPermissions();
+
+    const setPermission = (permission: string, checked: boolean) => {
+        if (readonly) return;
+
+        const newPermissions = checked ? [...value, permission] : value.filter(p => p !== permission);
+        onChange(newPermissions);
+    };
+
+    const toggleAll = (defs: ServerConfig['permissions']) => {
+        if (readonly) return;
+
+        const shouldEnable = defs.some(d => !value.includes(d.name));
+        const newPermissions = shouldEnable
+            ? [...new Set([...value, ...defs.map(d => d.name)])]
+            : value.filter(p => !defs.some(d => d.name === p));
+        onChange(newPermissions);
+    };
+
+    // Extract CRUD operation from permission name (e.g., "CreateAdministrator" -> "Create")
+    const getPermissionLabel = (permission: ServerConfig['permissions'][0], groupLabel: string) => {
+        const name = permission.name;
+        const crudPrefixes = ['Create', 'Read', 'Update', 'Delete'];
+
+        for (const prefix of crudPrefixes) {
+            if (name.startsWith(prefix)) {
+                // Check if the rest matches the group name (singular form)
+                const remainder = name.substring(prefix.length);
+                const groupSingular = groupLabel.replace(/s$/, ''); // Simple singularization
+                if (remainder.toLowerCase() === groupSingular.toLowerCase().replace(/\s/g, '')) {
+                    return prefix;
+                }
+            }
+        }
+
+        // Fallback to full name if not a CRUD operation
+        return i18n.t(name);
+    };
+
+    return (
+        <div className="w-full">
+            {/* Desktop Table View */}
+            <div className="hidden md:block border rounded-lg">
+                <Table>
+                    <TableBody>
+                        {groupedPermissions.map((section, index) => (
+                            <TableRow key={index} className="hover:bg-transparent">
+                                <TableCell className="bg-muted/50 p-3 align-top w-[150px] min-w-[150px] border-r">
+                                    <div className="space-y-2">
+                                        <div className="flex items-center gap-2">
+                                            <span className="font-semibold text-sm">
+                                                {i18n.t(section.label)}
+                                            </span>
+                                            <TooltipProvider>
+                                                <Tooltip>
+                                                    <TooltipTrigger asChild>
+                                                        <InfoIcon className="h-3 w-3 text-muted-foreground" />
+                                                    </TooltipTrigger>
+                                                    <TooltipContent side="right" className="max-w-[250px]">
+                                                        <p className="text-xs">
+                                                            {i18n.t(section.description)}
+                                                        </p>
+                                                    </TooltipContent>
+                                                </Tooltip>
+                                            </TooltipProvider>
+                                        </div>
+                                        {section.permissions.length > 1 && !readonly && (
+                                            <Button
+                                                variant="outline"
+                                                size="sm"
+                                                onClick={() => toggleAll(section.permissions)}
+                                                className="h-6 px-2 text-xs"
+                                            >
+                                                <Trans>Toggle all</Trans>
+                                            </Button>
+                                        )}
+                                    </div>
+                                </TableCell>
+                                {sortPermissions(section.permissions).map((permission, permIndex) => (
+                                    <TableCell
+                                        key={permission.name}
+                                        className="p-2 text-center align-top min-w-[80px]"
+                                        colSpan={section.permissions.length === 1 ? 4 : 1}
+                                    >
+                                        <TooltipProvider>
+                                            <Tooltip>
+                                                <TooltipTrigger asChild>
+                                                    <div className="flex flex-col items-center space-y-1.5">
+                                                        <Switch
+                                                            id={`${section.id}-${permission.name}`}
+                                                            checked={value.includes(permission.name)}
+                                                            onCheckedChange={checked =>
+                                                                setPermission(permission.name, checked)
+                                                            }
+                                                            disabled={readonly}
+                                                            className="scale-90"
+                                                        />
+                                                        <label
+                                                            htmlFor={`${section.id}-${permission.name}`}
+                                                            className="text-xs text-center cursor-pointer leading-tight"
+                                                        >
+                                                            {getPermissionLabel(permission, section.label)}
+                                                        </label>
+                                                    </div>
+                                                </TooltipTrigger>
+                                                <TooltipContent side="top" className="max-w-[250px]">
+                                                    <div className="text-xs">
+                                                        <div className="font-medium">
+                                                            {i18n.t(permission.name)}
+                                                        </div>
+                                                        <div className="text-accent-foreground/70 mt-1">
+                                                            {i18n.t(permission.description)}
+                                                        </div>
+                                                    </div>
+                                                </TooltipContent>
+                                            </Tooltip>
+                                        </TooltipProvider>
+                                    </TableCell>
+                                ))}
+                                {/* Fill remaining columns if less than 4 permissions */}
+                                {section.permissions.length < 4 &&
+                                    section.permissions.length > 1 &&
+                                    Array.from({ length: 4 - section.permissions.length }).map(
+                                        (_, fillIndex) => (
+                                            <TableCell key={`fill-${fillIndex}`} className="p-3" />
+                                        ),
+                                    )}
+                            </TableRow>
+                        ))}
+                    </TableBody>
+                </Table>
+            </div>
+
+            {/* Mobile Card View */}
+            <div className="md:hidden space-y-4">
+                {groupedPermissions.map((section, index) => (
+                    <div key={index} className="border rounded-lg p-4 bg-card">
+                        <div className="mb-3">
+                            <div className="flex items-center gap-2 mb-2">
+                                <span className="font-semibold text-sm">{i18n.t(section.label)}</span>
+                                <TooltipProvider>
+                                    <Tooltip>
+                                        <TooltipTrigger asChild>
+                                            <InfoIcon className="h-3 w-3 text-muted-foreground" />
+                                        </TooltipTrigger>
+                                        <TooltipContent side="right" className="max-w-[250px]">
+                                            <p className="text-xs">{i18n.t(section.description)}</p>
+                                        </TooltipContent>
+                                    </Tooltip>
+                                </TooltipProvider>
+                            </div>
+                            {section.permissions.length > 1 && !readonly && (
+                                <Button
+                                    variant="outline"
+                                    size="sm"
+                                    onClick={() => toggleAll(section.permissions)}
+                                    className="h-6 px-2 text-xs"
+                                >
+                                    <Trans>Toggle all</Trans>
+                                </Button>
+                            )}
+                        </div>
+                        <div className="grid grid-cols-2 gap-3">
+                            {sortPermissions(section.permissions).map(permission => (
+                                <div
+                                    key={permission.name}
+                                    className="flex items-center space-x-3 p-2 rounded border"
+                                >
+                                    <Switch
+                                        id={`mobile-${section.id}-${permission.name}`}
+                                        checked={value.includes(permission.name)}
+                                        onCheckedChange={checked => setPermission(permission.name, checked)}
+                                        disabled={readonly}
+                                    />
+                                    <div className="flex-1 min-w-0">
+                                        <TooltipProvider>
+                                            <Tooltip>
+                                                <TooltipTrigger asChild>
+                                                    <label
+                                                        htmlFor={`mobile-${section.id}-${permission.name}`}
+                                                        className="text-xs cursor-pointer block truncate"
+                                                    >
+                                                        {getPermissionLabel(permission, section.label)}
+                                                    </label>
+                                                </TooltipTrigger>
+                                                <TooltipContent side="top" className="max-w-[250px]">
+                                                    <div className="text-xs">
+                                                        <div className="font-medium">
+                                                            {i18n.t(permission.name)}
+                                                        </div>
+                                                        <div className="text-muted-foreground mt-1">
+                                                            {i18n.t(permission.description)}
+                                                        </div>
+                                                    </div>
+                                                </TooltipContent>
+                                            </Tooltip>
+                                        </TooltipProvider>
+                                    </div>
+                                </div>
+                            ))}
+                        </div>
+                    </div>
+                ))}
+            </div>
+        </div>
+    );
+}
+
+// Sort permissions in CRUD order
+const sortPermissions = (permissions: ServerConfig['permissions']) => {
+    const crudOrder = ['Create', 'Read', 'Update', 'Delete'];
+
+    return [...permissions].sort((a, b) => {
+        // Find the CRUD prefix for each permission
+        const aPrefix = crudOrder.find(prefix => a.name.startsWith(prefix));
+        const bPrefix = crudOrder.find(prefix => b.name.startsWith(prefix));
+
+        // If both have CRUD prefixes, sort by CRUD order
+        if (aPrefix && bPrefix) {
+            return crudOrder.indexOf(aPrefix) - crudOrder.indexOf(bPrefix);
+        }
+
+        // If only one has CRUD prefix, put it first
+        if (aPrefix && !bPrefix) return -1;
+        if (!aPrefix && bPrefix) return 1;
+
+        // Otherwise, keep original order
+        return 0;
+    });
+};

+ 5 - 3
packages/dashboard/src/app/routes/_authenticated/_roles/roles_.$id.tsx

@@ -19,7 +19,7 @@ import { useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
 import { Trans, useLingui } from '@/vdb/lib/trans.js';
 import { createFileRoute, useNavigate } from '@tanstack/react-router';
 import { toast } from 'sonner';
-import { PermissionsGrid } from './components/permissions-grid.js';
+import { PermissionsTableGrid } from './components/permissions-table-grid.js';
 import { createRoleDocument, roleDetailDocument, updateRoleDocument } from './roles.graphql.js';
 
 const pageId = 'role-detail';
@@ -61,7 +61,9 @@ function RoleDetailPage() {
         },
         params: { id: params.id },
         onSuccess: async data => {
-            toast.success(i18n.t(creatingNewEntity ? 'Successfully created role' : 'Successfully updated role'));
+            toast.success(
+                i18n.t(creatingNewEntity ? 'Successfully created role' : 'Successfully updated role'),
+            );
             resetForm();
             if (creatingNewEntity) {
                 await navigate({ to: `../$id`, params: { id: data.id } });
@@ -132,7 +134,7 @@ function RoleDetailPage() {
                             name="permissions"
                             label={<Trans>Permissions</Trans>}
                             render={({ field }) => (
-                                <PermissionsGrid
+                                <PermissionsTableGrid
                                     value={field.value ?? []}
                                     onChange={value => field.onChange(value)}
                                 />

+ 15 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/metadata-badges.tsx

@@ -0,0 +1,15 @@
+import { Badge } from '@/vdb/components/ui/badge.js';
+import React from 'react';
+
+export function MetadataBadges({ metadata }: Readonly<{ metadata?: Record<string, any> }>) {
+    if (!metadata || Object.keys(metadata).length === 0) return null;
+    return (
+        <div className="mt-2 flex flex-wrap gap-1">
+            {Object.entries(metadata).map(([key, value]) => (
+                <Badge key={key} variant="outline" className="text-xs">
+                    {key}: {String(value)}
+                </Badge>
+            ))}
+        </div>
+    );
+}

+ 21 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/price-display.tsx

@@ -0,0 +1,21 @@
+import { Money } from '@/vdb/components/data-display/money.js';
+import { Trans } from '@/vdb/lib/trans.js';
+
+export function PriceDisplay({
+    price,
+    priceWithTax,
+    currencyCode,
+}: Readonly<{
+    price: number;
+    priceWithTax: number;
+    currencyCode: string;
+}>) {
+    return (
+        <div className="text-right">
+            <Money value={priceWithTax} currency={currencyCode} />
+            <div className="text-xs text-muted-foreground">
+                <Trans>ex. tax:</Trans> <Money value={price} currency={currencyCode} />
+            </div>
+        </div>
+    );
+}

+ 87 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/shipping-method-test-result-wrapper.tsx

@@ -0,0 +1,87 @@
+import { Alert, AlertDescription } from '@/vdb/components/ui/alert.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import { Card, CardContent, CardHeader, CardTitle } from '@/vdb/components/ui/card.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { PlayIcon } from 'lucide-react';
+import React from 'react';
+
+interface ShippingMethodTestResultWrapperProps {
+    okToRun: boolean;
+    testDataUpdated: boolean;
+    hasTestedOnce: boolean;
+    onRunTest: () => void;
+    loading?: boolean;
+    children: React.ReactNode;
+    emptyState?: React.ReactNode;
+    showEmptyState?: boolean;
+    runTestLabel?: React.ReactNode;
+    loadingLabel?: React.ReactNode;
+}
+
+export function ShippingMethodTestResultWrapper({
+    okToRun,
+    testDataUpdated,
+    hasTestedOnce,
+    onRunTest,
+    loading = false,
+    children,
+    emptyState,
+    showEmptyState = false,
+    runTestLabel = <Trans>Run Test</Trans>,
+    loadingLabel = <Trans>Testing shipping method...</Trans>,
+}: Readonly<ShippingMethodTestResultWrapperProps>) {
+    const canRunTest = okToRun && testDataUpdated;
+    return (
+        <Card>
+            <CardHeader>
+                <CardTitle className="flex items-center justify-between">
+                    <span>
+                        <Trans>Test Results</Trans>
+                    </span>
+                    {okToRun && (
+                        <Button
+                            onClick={onRunTest}
+                            disabled={!canRunTest || loading}
+                            size="sm"
+                            className="ml-auto"
+                        >
+                            <PlayIcon className="mr-1 h-4 w-4" />
+                            {runTestLabel}
+                        </Button>
+                    )}
+                </CardTitle>
+            </CardHeader>
+            <CardContent>
+                {!okToRun && (
+                    <Alert>
+                        <AlertDescription>
+                            <Trans>
+                                Please add products and complete the shipping address to run the test.
+                            </Trans>
+                        </AlertDescription>
+                    </Alert>
+                )}
+
+                {okToRun && testDataUpdated && hasTestedOnce && (
+                    <Alert variant="destructive">
+                        <AlertDescription>
+                            <Trans>
+                                Test data has been updated. Click "Run Test" to see updated results.
+                            </Trans>
+                        </AlertDescription>
+                    </Alert>
+                )}
+
+                {loading && (
+                    <div className="text-center py-8">
+                        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
+                        <p className="mt-2 text-sm text-muted-foreground">{loadingLabel}</p>
+                    </div>
+                )}
+
+                {!loading && showEmptyState && emptyState}
+                {!loading && !showEmptyState && children}
+            </CardContent>
+        </Card>
+    );
+}

+ 255 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-address-form.tsx

@@ -0,0 +1,255 @@
+import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
+import { AccordionContent, AccordionItem, AccordionTrigger } from '@/vdb/components/ui/accordion.js';
+import { Form } from '@/vdb/components/ui/form.js';
+import { Input } from '@/vdb/components/ui/input.js';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/vdb/components/ui/select.js';
+import { api } from '@/vdb/graphql/api.js';
+import { graphql } from '@/vdb/graphql/graphql.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { useQuery } from '@tanstack/react-query';
+import { useEffect, useRef } from 'react';
+import { useForm } from 'react-hook-form';
+
+// Query document to fetch available countries
+const getAvailableCountriesDocument = graphql(`
+    query GetAvailableCountries {
+        countries(options: { filter: { enabled: { eq: true } } }) {
+            items {
+                id
+                code
+                name
+            }
+        }
+    }
+`);
+
+export interface TestAddress {
+    fullName: string;
+    company?: string;
+    streetLine1: string;
+    streetLine2?: string;
+    city: string;
+    province: string;
+    postalCode: string;
+    countryCode: string;
+    phoneNumber?: string;
+}
+
+interface TestAddressFormProps {
+    onAddressChange: (address: TestAddress) => void;
+}
+
+export function TestAddressForm({ onAddressChange }: Readonly<TestAddressFormProps>) {
+    const form = useForm<TestAddress>({
+        defaultValues: (() => {
+            try {
+                const stored = localStorage.getItem('shippingTestAddress');
+                return stored
+                    ? JSON.parse(stored)
+                    : {
+                          fullName: '',
+                          company: '',
+                          streetLine1: '',
+                          streetLine2: '',
+                          city: '',
+                          province: '',
+                          postalCode: '',
+                          countryCode: '',
+                          phoneNumber: '',
+                      };
+            } catch {
+                return {
+                    fullName: '',
+                    company: '',
+                    streetLine1: '',
+                    streetLine2: '',
+                    city: '',
+                    province: '',
+                    postalCode: '',
+                    countryCode: '',
+                    phoneNumber: '',
+                };
+            }
+        })(),
+    });
+
+    // Fetch available countries
+    const { data: countriesData, isLoading: isLoadingCountries } = useQuery({
+        queryKey: ['availableCountries'],
+        queryFn: () => api.query(getAvailableCountriesDocument),
+        staleTime: 1000 * 60 * 60 * 24, // 24 hours
+    });
+
+    const previousValuesRef = useRef<string>('');
+
+    // Use form subscription instead of watch() to avoid infinite loops
+    useEffect(() => {
+        const subscription = form.watch(value => {
+            const currentValueString = JSON.stringify(value);
+
+            // Only update if values actually changed
+            if (currentValueString !== previousValuesRef.current) {
+                previousValuesRef.current = currentValueString;
+
+                try {
+                    localStorage.setItem('shippingTestAddress', currentValueString);
+                } catch {
+                    // Ignore localStorage errors
+                }
+
+                if (value) {
+                    onAddressChange(value as TestAddress);
+                }
+            }
+        });
+
+        return () => subscription.unsubscribe();
+    }, [form, onAddressChange]);
+
+    useEffect(() => {
+        const initialAddress = form.getValues();
+        onAddressChange(initialAddress);
+    }, []);
+
+    const currentValues = form.getValues();
+
+    const getAddressSummary = () => {
+        const parts = [
+            currentValues.fullName,
+            currentValues.streetLine1,
+            currentValues.city,
+            currentValues.province,
+            currentValues.postalCode,
+            currentValues.countryCode,
+        ].filter(Boolean);
+        return parts.length > 0 ? parts.join(', ') : '';
+    };
+
+    const isComplete = !!(
+        currentValues.fullName &&
+        currentValues.streetLine1 &&
+        currentValues.city &&
+        currentValues.province &&
+        currentValues.postalCode &&
+        currentValues.countryCode
+    );
+
+    return (
+        <AccordionItem value="shipping-address">
+            <AccordionTrigger>
+                <div className="flex items-center justify-between w-full pr-2">
+                    <span>
+                        <Trans>Shipping Address</Trans>
+                    </span>
+                    {isComplete && (
+                        <span className="text-sm text-muted-foreground truncate max-w-md">
+                            {getAddressSummary()}
+                        </span>
+                    )}
+                </div>
+            </AccordionTrigger>
+            <AccordionContent className="px-2">
+                <Form {...form}>
+                    <div className="space-y-4">
+                        <div className="grid grid-cols-2 gap-4">
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="fullName"
+                                label={<Trans>Full Name</Trans>}
+                                render={({ field }) => <Input {...field} placeholder="John Smith" />}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="company"
+                                label={<Trans>Company</Trans>}
+                                render={({ field }) => (
+                                    <Input {...field} value={field.value || ''} placeholder="Company name" />
+                                )}
+                            />
+                        </div>
+
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="streetLine1"
+                            label={<Trans>Street Address</Trans>}
+                            render={({ field }) => <Input {...field} placeholder="123 Main Street" />}
+                        />
+
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="streetLine2"
+                            label={<Trans>Street Address 2</Trans>}
+                            render={({ field }) => (
+                                <Input
+                                    {...field}
+                                    value={field.value || ''}
+                                    placeholder="Apartment, suite, etc."
+                                />
+                            )}
+                        />
+
+                        <div className="grid grid-cols-3 gap-4">
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="city"
+                                label={<Trans>City</Trans>}
+                                render={({ field }) => <Input {...field} placeholder="New York" />}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="province"
+                                label={<Trans>State / Province</Trans>}
+                                render={({ field }) => <Input {...field} placeholder="NY" />}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="postalCode"
+                                label={<Trans>Postal Code</Trans>}
+                                render={({ field }) => <Input {...field} placeholder="10001" />}
+                            />
+                        </div>
+
+                        <div className="grid grid-cols-2 gap-4">
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="countryCode"
+                                label={<Trans>Country</Trans>}
+                                renderFormControl={false}
+                                render={({ field }) => (
+                                    <Select
+                                        onValueChange={field.onChange}
+                                        value={field.value}
+                                        disabled={isLoadingCountries}
+                                    >
+                                        <SelectTrigger>
+                                            <SelectValue placeholder="Select a country" />
+                                        </SelectTrigger>
+                                        <SelectContent>
+                                            {countriesData?.countries.items.map(country => (
+                                                <SelectItem key={country.code} value={country.code}>
+                                                    {country.name}
+                                                </SelectItem>
+                                            ))}
+                                        </SelectContent>
+                                    </Select>
+                                )}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="phoneNumber"
+                                label={<Trans>Phone Number</Trans>}
+                                render={({ field }) => (
+                                    <Input
+                                        {...field}
+                                        value={field.value || ''}
+                                        placeholder="+1 (555) 123-4567"
+                                    />
+                                )}
+                            />
+                        </div>
+                    </div>
+                </Form>
+            </AccordionContent>
+        </AccordionItem>
+    );
+}

+ 243 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-order-builder.tsx

@@ -0,0 +1,243 @@
+import {
+    ProductVariantSelector,
+    ProductVariantSelectorProps,
+} from '@/vdb/components/shared/product-variant-selector.js';
+import { AssetLike, VendureImage } from '@/vdb/components/shared/vendure-image.js';
+import { AccordionContent, AccordionItem, AccordionTrigger } from '@/vdb/components/ui/accordion.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import { Input } from '@/vdb/components/ui/input.js';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/vdb/components/ui/table.js';
+import { useChannel } from '@/vdb/hooks/use-channel.js';
+import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import {
+    ColumnDef,
+    flexRender,
+    getCoreRowModel,
+    useReactTable,
+    VisibilityState,
+} from '@tanstack/react-table';
+import { Trash2 } from 'lucide-react';
+import { useEffect, useState } from 'react';
+
+export interface TestOrderLine {
+    id: string;
+    name: string;
+    featuredAsset?: AssetLike;
+    sku: string;
+    unitPriceWithTax: number;
+    quantity: number;
+}
+
+interface TestOrderBuilderProps {
+    onOrderLinesChange: (lines: TestOrderLine[]) => void;
+}
+
+export function TestOrderBuilder({ onOrderLinesChange }: Readonly<TestOrderBuilderProps>) {
+    const { formatCurrency } = useLocalFormat();
+    const { activeChannel } = useChannel();
+    const [lines, setLines] = useState<TestOrderLine[]>(() => {
+        try {
+            const stored = localStorage.getItem('shippingTestOrder');
+            return stored ? JSON.parse(stored) : [];
+        } catch {
+            return [];
+        }
+    });
+    const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
+
+    const currencyCode = activeChannel?.defaultCurrencyCode ?? 'USD';
+    const subTotal = lines.reduce((sum, l) => sum + l.unitPriceWithTax * l.quantity, 0);
+
+    useEffect(() => {
+        try {
+            localStorage.setItem('shippingTestOrder', JSON.stringify(lines));
+        } catch {
+            // Ignore localStorage errors
+        }
+        onOrderLinesChange(lines);
+    }, [lines, onOrderLinesChange]);
+
+    const addProduct = (product: Parameters<ProductVariantSelectorProps['onProductVariantSelect']>[0]) => {
+        if (!lines.find(l => l.id === product.productVariantId)) {
+            const newLine: TestOrderLine = {
+                id: product.productVariantId,
+                name: product.productVariantName,
+                featuredAsset: product.productAsset ?? undefined,
+                quantity: 1,
+                sku: product.sku,
+                unitPriceWithTax: product.priceWithTax || 0,
+            };
+            setLines(prev => [...prev, newLine]);
+        }
+    };
+
+    const updateQuantity = (lineId: string, newQuantity: number) => {
+        if (newQuantity <= 0) {
+            removeLine(lineId);
+            return;
+        }
+        setLines(prev => prev.map(line => (line.id === lineId ? { ...line, quantity: newQuantity } : line)));
+    };
+
+    const removeLine = (lineId: string) => {
+        setLines(prev => prev.filter(line => line.id !== lineId));
+    };
+
+    const columns: ColumnDef<TestOrderLine>[] = [
+        {
+            header: 'Image',
+            accessorKey: 'preview',
+            cell: ({ row }) => {
+                const asset = row.original.featuredAsset ?? null;
+                return <VendureImage asset={asset} preset="tiny" />;
+            },
+        },
+        {
+            header: 'Product',
+            accessorKey: 'name',
+        },
+        {
+            header: 'SKU',
+            accessorKey: 'sku',
+        },
+        {
+            header: 'Unit price',
+            accessorKey: 'unitPriceWithTax',
+            cell: ({ row }) => {
+                return formatCurrency(row.original.unitPriceWithTax, currencyCode);
+            },
+        },
+        {
+            header: 'Quantity',
+            accessorKey: 'quantity',
+            cell: ({ row }) => {
+                return (
+                    <div className="flex gap-2 items-center">
+                        <Input
+                            type="number"
+                            min="1"
+                            value={row.original.quantity}
+                            onChange={e => updateQuantity(row.original.id, parseInt(e.target.value) || 1)}
+                            className="w-16"
+                        />
+                        <Button
+                            variant="outline"
+                            type="button"
+                            size="icon"
+                            onClick={() => removeLine(row.original.id)}
+                            className="h-8 w-8"
+                        >
+                            <Trash2 className="h-4 w-4" />
+                        </Button>
+                    </div>
+                );
+            },
+        },
+        {
+            header: 'Total',
+            accessorKey: 'total',
+            cell: ({ row }) => {
+                const total = row.original.unitPriceWithTax * row.original.quantity;
+                return formatCurrency(total, currencyCode);
+            },
+        },
+    ];
+
+    const table = useReactTable({
+        data: lines,
+        columns,
+        getCoreRowModel: getCoreRowModel(),
+        rowCount: lines.length,
+        onColumnVisibilityChange: setColumnVisibility,
+        state: {
+            columnVisibility,
+        },
+    });
+
+    return (
+        <AccordionItem value="test-order">
+            <AccordionTrigger>
+                <div className="flex items-center justify-between w-full pr-2">
+                    <span>
+                        <Trans>Test Order</Trans>
+                    </span>
+                    {lines.length > 0 && (
+                        <span className="text-sm text-muted-foreground">
+                            {lines.length} item{lines.length !== 1 ? 's' : ''} •{' '}
+                            {formatCurrency(subTotal, currencyCode)}
+                        </span>
+                    )}
+                </div>
+            </AccordionTrigger>
+            <AccordionContent className="space-y-4 px-2">
+                {lines.length > 0 ? (
+                    <div className="w-full">
+                        <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.map(row => (
+                                    <TableRow key={row.id}>
+                                        {row.getVisibleCells().map(cell => (
+                                            <TableCell key={cell.id}>
+                                                {flexRender(cell.column.columnDef.cell, cell.getContext())}
+                                            </TableCell>
+                                        ))}
+                                    </TableRow>
+                                ))}
+                                <TableRow>
+                                    <TableCell colSpan={columns.length} className="h-12">
+                                        <div className="my-4 flex justify-center">
+                                            <div className="max-w-lg">
+                                                <ProductVariantSelector onProductVariantSelect={addProduct} />
+                                            </div>
+                                        </div>
+                                    </TableCell>
+                                </TableRow>
+                                <TableRow>
+                                    <TableCell
+                                        colSpan={columns.length - 1}
+                                        className="text-right font-medium"
+                                    >
+                                        <Trans>Subtotal</Trans>
+                                    </TableCell>
+                                    <TableCell className="font-medium">
+                                        {formatCurrency(subTotal, currencyCode)}
+                                    </TableCell>
+                                </TableRow>
+                            </TableBody>
+                        </Table>
+                    </div>
+                ) : (
+                    <div className="space-y-4">
+                        <div className="text-center py-8 text-muted-foreground">
+                            <Trans>Add products to create a test order</Trans>
+                        </div>
+                        <div className="flex justify-center">
+                            <div className="max-w-lg">
+                                <ProductVariantSelector onProductVariantSelect={addProduct} />
+                            </div>
+                        </div>
+                    </div>
+                )}
+            </AccordionContent>
+        </AccordionItem>
+    );
+}

+ 0 - 32
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-method-dialog.tsx

@@ -1,32 +0,0 @@
-import { Button } from '@/vdb/components/ui/button.js';
-import {
-    Dialog,
-    DialogContent,
-    DialogDescription,
-    DialogHeader,
-    DialogTitle,
-    DialogTrigger,
-} from '@/vdb/components/ui/dialog.js';
-import { Trans } from '@/vdb/lib/trans.js';
-import { TestTube } from 'lucide-react';
-
-export function TestShippingMethodDialog() {
-    return (
-        <Dialog>
-            <DialogTrigger asChild>
-                <Button variant="secondary">
-                    <TestTube />
-                    <Trans>Test shipping method</Trans>
-                </Button>
-            </DialogTrigger>
-            <DialogContent className="min-w-[800px]">
-                <DialogHeader>
-                    <DialogTitle>Test shipping method</DialogTitle>
-                    <DialogDescription>
-                        Test your shipping method by simulating a new order.
-                    </DialogDescription>
-                </DialogHeader>
-            </DialogContent>
-        </Dialog>
-    );
-}

+ 97 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-methods-result.tsx

@@ -0,0 +1,97 @@
+import { Badge } from '@/vdb/components/ui/badge.js';
+import { useChannel } from '@/vdb/hooks/use-channel.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { Check } from 'lucide-react';
+import { MetadataBadges } from './metadata-badges.js';
+import { PriceDisplay } from './price-display.js';
+import { ShippingMethodTestResultWrapper } from './shipping-method-test-result-wrapper.js';
+
+export interface ShippingMethodQuote {
+    id: string;
+    name: string;
+    code: string;
+    description: string;
+    price: number;
+    priceWithTax: number;
+    metadata?: Record<string, any>;
+}
+
+interface ShippingEligibilityTestResultProps {
+    testResult?: ShippingMethodQuote[];
+    okToRun: boolean;
+    testDataUpdated: boolean;
+    hasTestedOnce: boolean;
+    onRunTest: () => void;
+    loading?: boolean;
+}
+
+export function TestShippingMethodsResult({
+    testResult,
+    okToRun,
+    testDataUpdated,
+    hasTestedOnce,
+    onRunTest,
+    loading = false,
+}: Readonly<ShippingEligibilityTestResultProps>) {
+    const { activeChannel } = useChannel();
+    const currencyCode = activeChannel?.defaultCurrencyCode ?? 'USD';
+    const hasResults = testResult && testResult.length > 0;
+    const showEmptyState = testResult && testResult.length === 0 && !loading && !testDataUpdated;
+
+    return (
+        <ShippingMethodTestResultWrapper
+            okToRun={okToRun}
+            testDataUpdated={testDataUpdated}
+            hasTestedOnce={hasTestedOnce}
+            onRunTest={onRunTest}
+            loading={loading}
+            showEmptyState={showEmptyState}
+            emptyState={
+                <div className="text-center py-8">
+                    <p className="text-muted-foreground">
+                        <Trans>No eligible shipping methods found for this order.</Trans>
+                    </p>
+                </div>
+            }
+            loadingLabel={<Trans>Testing shipping methods...</Trans>}
+        >
+            {hasResults && (
+                <div className="space-y-3">
+                    <div className="flex items-center gap-2 mb-4">
+                        <span className="text-sm font-medium">
+                            <Trans>
+                                Found {testResult.length} eligible shipping method
+                                {testResult.length !== 1 ? 's' : ''}
+                            </Trans>
+                        </span>
+                    </div>
+                    {testResult.map(method => (
+                        <div
+                            key={method.id}
+                            className="flex items-center text-sm justify-between p-3 rounded-lg bg-muted/50"
+                        >
+                            <div className="flex-1">
+                                <div className="flex gap-1">
+                                    <Check className="h-5 w-5 text-success" />
+                                    <div className="">{method.name}</div>
+                                </div>
+                                <Badge variant="secondary">{method.code}</Badge>
+                                {method.description && (
+                                    <div className="text-sm text-muted-foreground mt-1">
+                                        {method.description}
+                                    </div>
+                                )}
+                                <MetadataBadges metadata={method.metadata} />
+                            </div>
+                            <PriceDisplay
+                                price={method.price}
+                                priceWithTax={method.priceWithTax}
+                                currencyCode={currencyCode}
+                            />
+                        </div>
+                    ))}
+                </div>
+            )}
+        </ShippingMethodTestResultWrapper>
+    );
+}

+ 41 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-methods-sheet.tsx

@@ -0,0 +1,41 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    Sheet,
+    SheetContent,
+    SheetDescription,
+    SheetHeader,
+    SheetTitle,
+    SheetTrigger,
+} from '@/vdb/components/ui/sheet.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { FlaskConical } from 'lucide-react';
+import { useState } from 'react';
+import { TestShippingMethods } from './test-shipping-methods.js';
+
+export function TestShippingMethodsSheet() {
+    const [open, setOpen] = useState(false);
+
+    return (
+        <Sheet open={open} onOpenChange={setOpen}>
+            <SheetTrigger asChild>
+                <Button variant="secondary">
+                    <FlaskConical />
+                    <Trans>Test</Trans>
+                </Button>
+            </SheetTrigger>
+            <SheetContent className="w-[800px] sm:max-w-[800px]">
+                <SheetHeader>
+                    <SheetTitle>
+                        <Trans>Test shipping methods</Trans>
+                    </SheetTitle>
+                    <SheetDescription>
+                        <Trans>Test your shipping methods by simulating a new order.</Trans>
+                    </SheetDescription>
+                </SheetHeader>
+                <div className="mt-6">
+                    <TestShippingMethods />
+                </div>
+            </SheetContent>
+        </Sheet>
+    );
+}

+ 74 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-methods.tsx

@@ -0,0 +1,74 @@
+import { Accordion } from '@/vdb/components/ui/accordion.js';
+import { api } from '@/vdb/graphql/api.js';
+import { useQuery } from '@tanstack/react-query';
+import { testEligibleShippingMethodsDocument } from '../shipping-methods.graphql.js';
+import { TestAddressForm } from './test-address-form.js';
+import { TestOrderBuilder } from './test-order-builder.js';
+import { TestShippingMethodsResult } from './test-shipping-methods-result.js';
+import { useShippingMethodTestState } from './use-shipping-method-test-state.js';
+
+export function TestShippingMethods() {
+    const {
+        testAddress,
+        testOrderLines,
+        testDataUpdated,
+        hasTestedOnce,
+        expandedAccordions,
+        setExpandedAccordions,
+        allTestDataPresent,
+        handleAddressChange,
+        handleOrderLinesChange,
+        markTestRun,
+    } = useShippingMethodTestState();
+
+    const { data, isLoading, refetch } = useQuery({
+        queryKey: ['testEligibleShippingMethods', testAddress, testOrderLines],
+        queryFn: async () => {
+            if (!testAddress || !testOrderLines.length) {
+                return { testEligibleShippingMethods: [] };
+            }
+            return api.query(testEligibleShippingMethodsDocument, {
+                input: {
+                    shippingAddress: testAddress,
+                    lines: testOrderLines.map(l => ({
+                        productVariantId: l.id,
+                        quantity: l.quantity,
+                    })),
+                },
+            });
+        },
+        enabled: false,
+    });
+
+    const testResult = data?.testEligibleShippingMethods || [];
+
+    const runTest = () => {
+        if (allTestDataPresent) {
+            markTestRun();
+            refetch();
+        }
+    };
+
+    return (
+        <div className="space-y-6 overflow-y-auto max-h-[calc(100vh-200px)] px-4">
+            <Accordion
+                type="multiple"
+                value={expandedAccordions}
+                onValueChange={setExpandedAccordions}
+                className="w-full"
+            >
+                <TestOrderBuilder onOrderLinesChange={handleOrderLinesChange} />
+                <TestAddressForm onAddressChange={handleAddressChange} />
+            </Accordion>
+
+            <TestShippingMethodsResult
+                testResult={testResult}
+                okToRun={allTestDataPresent}
+                testDataUpdated={testDataUpdated}
+                hasTestedOnce={hasTestedOnce}
+                onRunTest={runTest}
+                loading={isLoading}
+            />
+        </div>
+    );
+}

+ 90 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-single-method-result.tsx

@@ -0,0 +1,90 @@
+import { useChannel } from '@/vdb/hooks/use-channel.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { ResultOf } from 'gql.tada';
+import { Check } from 'lucide-react';
+import { testShippingMethodDocument } from '../shipping-methods.graphql.js';
+import { MetadataBadges } from './metadata-badges.js';
+import { PriceDisplay } from './price-display.js';
+import { ShippingMethodTestResultWrapper } from './shipping-method-test-result-wrapper.js';
+
+export type TestShippingMethodResult = ResultOf<typeof testShippingMethodDocument>['testShippingMethod'];
+
+interface TestSingleMethodResultProps {
+    testResult?: TestShippingMethodResult;
+    okToRun: boolean;
+    testDataUpdated: boolean;
+    hasTestedOnce: boolean;
+    onRunTest: () => void;
+    loading?: boolean;
+}
+
+export function TestSingleMethodResult({
+    testResult,
+    okToRun,
+    testDataUpdated,
+    hasTestedOnce,
+    onRunTest,
+    loading = false,
+}: Readonly<TestSingleMethodResultProps>) {
+    const { activeChannel } = useChannel();
+    const currencyCode = activeChannel?.defaultCurrencyCode ?? 'USD';
+    const showEmptyState = testResult === undefined && hasTestedOnce && !testDataUpdated && !loading;
+
+    return (
+        <ShippingMethodTestResultWrapper
+            okToRun={okToRun}
+            testDataUpdated={testDataUpdated}
+            hasTestedOnce={hasTestedOnce}
+            onRunTest={onRunTest}
+            loading={loading}
+            showEmptyState={showEmptyState}
+            emptyState={
+                <div className="text-center py-8 text-muted-foreground">
+                    <Trans>Click "Run Test" to test this shipping method.</Trans>
+                </div>
+            }
+            loadingLabel={<Trans>Testing shipping method...</Trans>}
+        >
+            {testResult && (
+                <div className="space-y-4">
+                    {testResult.eligible ? (
+                        <div className="space-y-3">
+                            {testResult.quote && (
+                                <div className="p-3 border rounded-lg bg-muted/50">
+                                    <div className="flex justify-between items-center">
+                                        <div className="flex items-center gap-2">
+                                            <Check className="h-5 w-5 text-success" />
+                                            <span className="text-sm">
+                                                <Trans>Shipping method is eligible for this order</Trans>
+                                            </span>
+                                        </div>
+                                        <PriceDisplay
+                                            price={testResult.quote.price}
+                                            priceWithTax={testResult.quote.priceWithTax}
+                                            currencyCode={currencyCode}
+                                        />
+                                    </div>
+                                    <div className="flex-1">
+                                        <MetadataBadges metadata={testResult.quote.metadata} />
+                                    </div>
+                                </div>
+                            )}
+                        </div>
+                    ) : (
+                        <div className="text-center py-8">
+                            <p className="text-destructive">
+                                <Trans>Shipping method is not eligible for this order</Trans>
+                            </p>
+                            <p className="text-sm text-muted-foreground mt-2">
+                                <Trans>
+                                    This shipping method's eligibility checker conditions are not met for the
+                                    current order and shipping address.
+                                </Trans>
+                            </p>
+                        </div>
+                    )}
+                </div>
+            )}
+        </ShippingMethodTestResultWrapper>
+    );
+}

+ 56 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-single-shipping-method-sheet.tsx

@@ -0,0 +1,56 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    Sheet,
+    SheetContent,
+    SheetDescription,
+    SheetHeader,
+    SheetTitle,
+    SheetTrigger,
+} from '@/vdb/components/ui/sheet.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { VariablesOf } from 'gql.tada';
+import { FlaskConical } from 'lucide-react';
+import { useState } from 'react';
+import { testShippingMethodDocument } from '../shipping-methods.graphql.js';
+import { TestSingleShippingMethod } from './test-single-shipping-method.js';
+
+interface TestSingleShippingMethodDialogProps {
+    checker?: VariablesOf<typeof testShippingMethodDocument>['input']['checker'];
+    calculator?: VariablesOf<typeof testShippingMethodDocument>['input']['calculator'];
+}
+
+export function TestSingleShippingMethodSheet({
+    checker,
+    calculator,
+}: Readonly<TestSingleShippingMethodDialogProps>) {
+    const [open, setOpen] = useState(false);
+
+    return (
+        <Sheet open={open} onOpenChange={setOpen}>
+            <SheetTrigger asChild>
+                <Button variant="secondary">
+                    <FlaskConical />
+                    <Trans>Test</Trans>
+                </Button>
+            </SheetTrigger>
+            <SheetContent className="w-[800px] sm:max-w-[800px]">
+                <SheetHeader>
+                    <SheetTitle>
+                        <Trans>Test Shipping Method</Trans>
+                    </SheetTitle>
+                    <SheetDescription>
+                        <Trans>
+                            Test this shipping method by simulating an order to see if it's eligible and what
+                            the shipping cost would be.
+                        </Trans>
+                    </SheetDescription>
+                </SheetHeader>
+                <div className="mt-6">
+                    {checker && calculator ? (
+                        <TestSingleShippingMethod checker={checker} calculator={calculator} />
+                    ) : null}
+                </div>
+            </SheetContent>
+        </Sheet>
+    );
+}

+ 82 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-single-shipping-method.tsx

@@ -0,0 +1,82 @@
+import { Accordion } from '@/vdb/components/ui/accordion.js';
+import { api } from '@/vdb/graphql/api.js';
+import { useQuery } from '@tanstack/react-query';
+import { VariablesOf } from 'gql.tada';
+import { testShippingMethodDocument } from '../shipping-methods.graphql.js';
+import { TestAddressForm } from './test-address-form.js';
+import { TestOrderBuilder } from './test-order-builder.js';
+import { TestSingleMethodResult } from './test-single-method-result.js';
+import { useShippingMethodTestState } from './use-shipping-method-test-state.js';
+
+interface TestSingleShippingMethodProps {
+    checker: VariablesOf<typeof testShippingMethodDocument>['input']['checker'];
+    calculator: VariablesOf<typeof testShippingMethodDocument>['input']['calculator'];
+}
+
+export function TestSingleShippingMethod({ checker, calculator }: Readonly<TestSingleShippingMethodProps>) {
+    const {
+        testAddress,
+        testOrderLines,
+        testDataUpdated,
+        hasTestedOnce,
+        expandedAccordions,
+        setExpandedAccordions,
+        allTestDataPresent,
+        handleAddressChange,
+        handleOrderLinesChange,
+        markTestRun,
+    } = useShippingMethodTestState();
+
+    const { data, isLoading, refetch } = useQuery({
+        queryKey: ['testShippingMethod', testAddress, testOrderLines, checker, calculator],
+        queryFn: async () => {
+            if (!testAddress || !testOrderLines.length) {
+                return { testShippingMethod: undefined };
+            }
+            return api.query(testShippingMethodDocument, {
+                input: {
+                    shippingAddress: testAddress,
+                    lines: testOrderLines.map(l => ({
+                        productVariantId: l.id,
+                        quantity: l.quantity,
+                    })),
+                    checker,
+                    calculator,
+                },
+            });
+        },
+        enabled: false,
+    });
+
+    const testResult = data?.testShippingMethod;
+
+    const runTest = () => {
+        if (allTestDataPresent) {
+            markTestRun();
+            refetch();
+        }
+    };
+
+    return (
+        <div className="space-y-6 overflow-y-auto max-h-[calc(100vh-200px)] px-4">
+            <Accordion
+                type="multiple"
+                value={expandedAccordions}
+                onValueChange={setExpandedAccordions}
+                className="w-full"
+            >
+                <TestOrderBuilder onOrderLinesChange={handleOrderLinesChange} />
+                <TestAddressForm onAddressChange={handleAddressChange} />
+            </Accordion>
+
+            <TestSingleMethodResult
+                testResult={testResult}
+                okToRun={allTestDataPresent}
+                testDataUpdated={testDataUpdated}
+                hasTestedOnce={hasTestedOnce}
+                onRunTest={runTest}
+                loading={isLoading}
+            />
+        </div>
+    );
+}

+ 67 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/use-shipping-method-test-state.ts

@@ -0,0 +1,67 @@
+import { useCallback, useState } from 'react';
+
+import { TestAddress } from './test-address-form.js';
+import { TestOrderLine } from './test-order-builder.js';
+
+export function useShippingMethodTestState() {
+    const [testAddress, setTestAddress] = useState<TestAddress | null>(null);
+    const [testOrderLines, setTestOrderLines] = useState<TestOrderLine[]>([]);
+    const [testDataUpdated, setTestDataUpdated] = useState(true);
+    const [hasTestedOnce, setHasTestedOnce] = useState(false);
+    const [expandedAccordions, setExpandedAccordions] = useState<string[]>([
+        'test-order',
+        'shipping-address',
+    ]);
+    const [lastTestedAddress, setLastTestedAddress] = useState<TestAddress | null>(null);
+    const [lastTestedOrderLines, setLastTestedOrderLines] = useState<TestOrderLine[]>([]);
+
+    const allTestDataPresent = !!(testAddress && testOrderLines && testOrderLines.length > 0);
+
+    const handleAddressChange = useCallback(
+        (address: TestAddress) => {
+            setTestAddress(address);
+            if (hasTestedOnce && JSON.stringify(address) !== JSON.stringify(lastTestedAddress)) {
+                setTestDataUpdated(true);
+            }
+        },
+        [hasTestedOnce, lastTestedAddress],
+    );
+
+    const handleOrderLinesChange = useCallback(
+        (lines: TestOrderLine[]) => {
+            setTestOrderLines(lines);
+            if (hasTestedOnce && JSON.stringify(lines) !== JSON.stringify(lastTestedOrderLines)) {
+                setTestDataUpdated(true);
+            }
+        },
+        [hasTestedOnce, lastTestedOrderLines],
+    );
+
+    // runTest now only updates state; actual query logic is handled in the component
+    const markTestRun = () => {
+        setTestDataUpdated(false);
+        setHasTestedOnce(true);
+        setLastTestedAddress(testAddress);
+        setLastTestedOrderLines(testOrderLines);
+        setExpandedAccordions([]); // Collapse all accordions
+    };
+
+    return {
+        testAddress,
+        setTestAddress,
+        testOrderLines,
+        setTestOrderLines,
+        testDataUpdated,
+        setTestDataUpdated,
+        hasTestedOnce,
+        setHasTestedOnce,
+        expandedAccordions,
+        setExpandedAccordions,
+        lastTestedAddress,
+        lastTestedOrderLines,
+        allTestDataPresent,
+        handleAddressChange,
+        handleOrderLinesChange,
+        markTestRun,
+    };
+}

+ 27 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods.graphql.ts

@@ -108,3 +108,30 @@ export const removeShippingMethodsFromChannelDocument = graphql(`
         }
     }
 `);
+
+export const testEligibleShippingMethodsDocument = graphql(`
+    query TestEligibleShippingMethods($input: TestEligibleShippingMethodsInput!) {
+        testEligibleShippingMethods(input: $input) {
+            id
+            name
+            code
+            description
+            price
+            priceWithTax
+            metadata
+        }
+    }
+`);
+
+export const testShippingMethodDocument = graphql(`
+    query TestShippingMethod($input: TestShippingMethodInput!) {
+        testShippingMethod(input: $input) {
+            eligible
+            quote {
+                price
+                priceWithTax
+                metadata
+            }
+        }
+    }
+`);

+ 2 - 2
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods.tsx

@@ -11,7 +11,7 @@ import {
     DeleteShippingMethodsBulkAction,
     RemoveShippingMethodsFromChannelBulkAction,
 } from './components/shipping-method-bulk-actions.js';
-import { TestShippingMethodDialog } from './components/test-shipping-method-dialog.js';
+import { TestShippingMethodsSheet } from './components/test-shipping-methods-sheet.js';
 import { shippingMethodListQuery } from './shipping-methods.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_shipping-methods/shipping-methods')({
@@ -58,6 +58,7 @@ function ShippingMethodListPage() {
             ]}
         >
             <PageActionBarRight>
+                <TestShippingMethodsSheet />
                 <PermissionGuard requires={['CreateShippingMethod']}>
                     <Button asChild>
                         <Link to="./new">
@@ -66,7 +67,6 @@ function ShippingMethodListPage() {
                         </Link>
                     </Button>
                 </PermissionGuard>
-                <TestShippingMethodDialog />
             </PageActionBarRight>
         </ListPage>
     );

+ 24 - 4
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods_.$id.tsx

@@ -24,6 +24,7 @@ import { toast } from 'sonner';
 import { FulfillmentHandlerSelector } from './components/fulfillment-handler-selector.js';
 import { ShippingCalculatorSelector } from './components/shipping-calculator-selector.js';
 import { ShippingEligibilityCheckerSelector } from './components/shipping-eligibility-checker-selector.js';
+import { TestSingleShippingMethodSheet } from './components/test-single-shipping-method-sheet.js';
 import {
     createShippingMethodDocument,
     shippingMethodDetailDocument,
@@ -84,19 +85,35 @@ function ShippingMethodDetailPage() {
         },
         params: { id: params.id },
         onSuccess: async data => {
-            toast.success(i18n.t(creatingNewEntity ? 'Successfully created shipping method' : 'Successfully updated shipping method'));
+            toast.success(
+                i18n.t(
+                    creatingNewEntity
+                        ? 'Successfully created shipping method'
+                        : 'Successfully updated shipping method',
+                ),
+            );
             resetForm();
             if (creatingNewEntity) {
                 await navigate({ to: `../$id`, params: { id: data.id } });
             }
         },
         onError: err => {
-            toast.error(i18n.t(creatingNewEntity ? 'Failed to create shipping method' : 'Failed to update shipping method'), {
-                description: err instanceof Error ? err.message : 'Unknown error',
-            });
+            toast.error(
+                i18n.t(
+                    creatingNewEntity
+                        ? 'Failed to create shipping method'
+                        : 'Failed to update shipping method',
+                ),
+                {
+                    description: err instanceof Error ? err.message : 'Unknown error',
+                },
+            );
         },
     });
 
+    const checker = form.watch('checker');
+    const calculator = form.watch('calculator');
+
     return (
         <Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
             <PageTitle>
@@ -104,6 +121,9 @@ function ShippingMethodDetailPage() {
             </PageTitle>
             <PageActionBar>
                 <PageActionBarRight>
+                    {!creatingNewEntity && entity && (
+                        <TestSingleShippingMethodSheet checker={checker} calculator={calculator} />
+                    )}
                     <PermissionGuard requires={['UpdateShippingMethod']}>
                         <Button
                             type="submit"

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

@@ -13,7 +13,7 @@ import { Button } from '../ui/button.js';
  * ```tsx
  * // Basic usage with ID (relative navigation)
  * <DetailPageButton id="123" label="Product Name" />
- * 
+ *
  *
  * @example
  * ```tsx
@@ -36,18 +36,20 @@ export function DetailPageButton({
     label,
     disabled,
     search,
-}: {
+    className,
+}: Readonly<{
     label: string | React.ReactNode;
     id?: string;
     href?: string;
     disabled?: boolean;
     search?: Record<string, string>;
-}) {
+    className?: string;
+}>) {
     if (!id && !href) {
         return <span>{label}</span>;
     }
     return (
-        <Button asChild variant="ghost" disabled={disabled}>
+        <Button asChild variant="ghost" disabled={disabled} className={className}>
             <Link to={href ?? `./${id}`} search={search ?? {}} preload={false}>
                 {label}
                 {!disabled && <ChevronRight className="h-3 w-3 text-muted-foreground" />}

+ 0 - 188
packages/dashboard/src/lib/components/shared/history-timeline/history-entry.tsx

@@ -1,188 +0,0 @@
-import { Badge } from '@/vdb/components/ui/badge.js';
-import { Button } from '@/vdb/components/ui/button.js';
-import {
-    DropdownMenu,
-    DropdownMenuContent,
-    DropdownMenuItem,
-    DropdownMenuTrigger,
-} from '@/vdb/components/ui/dropdown-menu.js';
-import { Separator } from '@/vdb/components/ui/separator.js';
-import { Trans } from '@/vdb/lib/trans.js';
-import { cn } from '@/vdb/lib/utils.js';
-import { MoreVerticalIcon, PencilIcon, TrashIcon } from 'lucide-react';
-import { HistoryEntryDate } from './history-entry-date.js';
-
-export interface HistoryEntryItem {
-    id: string;
-    type: string;
-    createdAt: string;
-    isPublic: boolean;
-    administrator?: {
-        id: string;
-        firstName: string;
-        lastName: string;
-    } | null;
-    data: any;
-}
-
-interface OrderCustomer {
-    firstName: string;
-    lastName: string;
-}
-
-interface HistoryEntryProps {
-    entry: HistoryEntryItem;
-    isNoteEntry: boolean;
-    timelineIcon: React.ReactNode;
-    title: string | React.ReactNode;
-    children: React.ReactNode;
-    isPrimary?: boolean;
-    customer?: OrderCustomer | null;
-    onEditNote?: (noteId: string, note: string, isPrivate: boolean) => void;
-    onDeleteNote?: (noteId: string) => void;
-}
-
-export function HistoryEntry({
-    entry,
-    isNoteEntry,
-    timelineIcon,
-    title,
-    children,
-    isPrimary = true,
-    customer,
-    onEditNote,
-    onDeleteNote,
-}: Readonly<HistoryEntryProps>) {
-    const getIconColor = (type: string) => {
-        // Check for success states (payment settled, order delivered)
-        if (type === 'ORDER_PAYMENT_TRANSITION' && entry.data.to === 'Settled') {
-            return 'bg-success text-success-foreground';
-        }
-        if (type === 'ORDER_STATE_TRANSITION' && entry.data.to === 'Delivered') {
-            return 'bg-success text-success-foreground';
-        }
-        if (type === 'ORDER_FULFILLMENT_TRANSITION' && entry.data.to === 'Delivered') {
-            return 'bg-success text-success-foreground';
-        }
-        
-        // Check for destructive states (cancellations)
-        if (type === 'ORDER_CANCELLATION') {
-            return 'bg-destructive text-destructive-foreground';
-        }
-        if (type === 'ORDER_STATE_TRANSITION' && entry.data.to === 'Cancelled') {
-            return 'bg-destructive text-destructive-foreground';
-        }
-        if (type === 'ORDER_PAYMENT_TRANSITION' && (entry.data.to === 'Declined' || entry.data.to === 'Cancelled')) {
-            return 'bg-destructive text-destructive-foreground';
-        }
-        
-        // All other entries use neutral colors
-        return 'bg-muted text-muted-foreground';
-    };
-
-    const getActorName = () => {
-        if (entry.administrator) {
-            return `${entry.administrator.firstName} ${entry.administrator.lastName}`;
-        } else if (customer) {
-            return `${customer.firstName} ${customer.lastName}`;
-        }
-        return '';
-    };
-
-    return (
-        <div key={entry.id} className="relative group">
-            <div
-                className={`flex gap-3 p-3 rounded-lg hover:bg-muted/30 transition-colors ${!isPrimary ? 'opacity-75' : ''}`}
-            >
-                <div className={cn(`relative z-10 flex-shrink-0 ${isNoteEntry ? 'ml-2' : ''}`, isPrimary ? '-ml-1' : '')}>
-                    <div
-                        className={`rounded-full flex items-center justify-center ${isPrimary ? 'h-8 w-8' : 'h-6 w-6'} ${getIconColor(entry.type)} border-2 border-background ${isPrimary ? 'shadow-sm' : 'shadow-none'}`}
-                    >
-                        <div className={isPrimary ? 'text-current' : 'text-current scale-75'}>
-                            {timelineIcon}
-                        </div>
-                    </div>
-                </div>
-
-                <div className="flex-1 min-w-0">
-                    <div className="flex items-start justify-between">
-                        <div className="flex-1 min-w-0">
-                            <h4
-                                className={`text-sm ${isPrimary ? 'font-medium text-foreground' : 'font-normal text-muted-foreground'}`}
-                            >
-                                {title}
-                            </h4>
-                            <div className="mt-1">
-                                {entry.type === 'ORDER_NOTE' ? (
-                                    <div className={`space-y-${isPrimary ? '2' : '1'}`}>
-                                        <p className={`${isPrimary ? 'text-sm' : 'text-xs'} text-foreground`}>
-                                            {entry.data.note}
-                                        </p>
-                                        <div className="flex items-center gap-2">
-                                            <Badge
-                                                variant={entry.isPublic ? 'outline' : 'secondary'}
-                                                className="text-xs"
-                                            >
-                                                {entry.isPublic ? 'Public' : 'Private'}
-                                            </Badge>
-                                            {isPrimary && onEditNote && onDeleteNote && (
-                                                <DropdownMenu>
-                                                    <DropdownMenuTrigger asChild>
-                                                        <Button variant="ghost" size="sm" className="h-6 w-6 p-0">
-                                                            <MoreVerticalIcon className="h-3 w-3" />
-                                                        </Button>
-                                                    </DropdownMenuTrigger>
-                                                    <DropdownMenuContent align="end">
-                                                        <DropdownMenuItem
-                                                            onClick={() => {
-                                                                onEditNote(
-                                                                    entry.id,
-                                                                    entry.data.note,
-                                                                    !entry.isPublic,
-                                                                );
-                                                            }}
-                                                            className="cursor-pointer"
-                                                        >
-                                                            <PencilIcon className="mr-2 h-4 w-4" />
-                                                            <Trans>Edit</Trans>
-                                                        </DropdownMenuItem>
-                                                        <Separator className="my-1" />
-                                                        <DropdownMenuItem
-                                                            onClick={() => onDeleteNote(entry.id)}
-                                                            className="cursor-pointer text-red-600 focus:text-red-600"
-                                                        >
-                                                            <TrashIcon className="mr-2 h-4 w-4" />
-                                                            <span>Delete</span>
-                                                        </DropdownMenuItem>
-                                                    </DropdownMenuContent>
-                                                </DropdownMenu>
-                                            )}
-                                        </div>
-                                    </div>
-                                ) : (
-                                    children
-                                )}
-                            </div>
-                        </div>
-
-                        <div className="flex items-center gap-2 ml-4 flex-shrink-0">
-                            <div className="text-right">
-                                <HistoryEntryDate
-                                    date={entry.createdAt}
-                                    className={`text-xs cursor-help ${isPrimary ? 'text-muted-foreground' : 'text-muted-foreground/70'}`}
-                                />
-                                {getActorName() && (
-                                    <div
-                                        className={`text-xs ${isPrimary ? 'text-muted-foreground' : 'text-muted-foreground/70'}`}
-                                    >
-                                        {getActorName()}
-                                    </div>
-                                )}
-                            </div>
-                        </div>
-                    </div>
-                </div>
-            </div>
-        </div>
-    );
-}

+ 65 - 0
packages/dashboard/src/lib/components/shared/history-timeline/history-note-entry.tsx

@@ -0,0 +1,65 @@
+import { Badge } from '@/vdb/components/ui/badge.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/vdb/components/ui/dropdown-menu.js';
+import { Separator } from '@/vdb/components/ui/separator.js';
+import { HistoryEntry, HistoryEntryProps } from '@/vdb/framework/history-entry/history-entry.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { MoreVerticalIcon, PencilIcon, TrashIcon } from 'lucide-react';
+
+interface HistoryNoteEntryProps extends Readonly<HistoryEntryProps> {
+    onEditNote?: (noteId: string, note: string, isPrivate: boolean) => void;
+    onDeleteNote?: (noteId: string) => void;
+}
+
+export function HistoryNoteEntry(props: HistoryNoteEntryProps) {
+    const { entry, isPrimary, onEditNote, onDeleteNote } = props;
+    return (
+        <HistoryEntry {...props}>
+            <div className={isPrimary ? 'space-y-2' : 'space-y-1'}>
+                <div className="space-y-1">
+                    <p className={`${isPrimary ? 'text-sm' : 'text-xs'} text-foreground`}>
+                        {entry.data.note}
+                    </p>
+                </div>
+                {onEditNote && onDeleteNote && (
+                    <div className="flex items-center gap-2">
+                        <Badge variant={entry.isPublic ? 'outline' : 'secondary'} className="text-xs">
+                            {entry.isPublic ? 'Public' : 'Private'}
+                        </Badge>
+                        <DropdownMenu>
+                            <DropdownMenuTrigger asChild>
+                                <Button variant="ghost" size="sm" className="h-6 w-6 p-0">
+                                    <MoreVerticalIcon className="h-3 w-3" />
+                                </Button>
+                            </DropdownMenuTrigger>
+                            <DropdownMenuContent align="end">
+                                <DropdownMenuItem
+                                    onClick={() => {
+                                        onEditNote(entry.id, entry.data.note, !entry.isPublic);
+                                    }}
+                                    className="cursor-pointer"
+                                >
+                                    <PencilIcon className="mr-2 h-4 w-4" />
+                                    <Trans>Edit</Trans>
+                                </DropdownMenuItem>
+                                <Separator className="my-1" />
+                                <DropdownMenuItem
+                                    onClick={() => onDeleteNote(entry.id)}
+                                    className="cursor-pointer text-red-600 focus:text-red-600"
+                                >
+                                    <TrashIcon className="mr-2 h-4 w-4" />
+                                    <span>Delete</span>
+                                </DropdownMenuItem>
+                            </DropdownMenuContent>
+                        </DropdownMenu>
+                    </div>
+                )}
+            </div>
+        </HistoryEntry>
+    );
+}

+ 141 - 0
packages/dashboard/src/lib/components/shared/history-timeline/history-timeline-with-grouping.tsx

@@ -0,0 +1,141 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import { HistoryEntryItem } from '@/vdb/framework/extension-api/types/history-entries.js';
+import { getCustomHistoryEntryForType } from '@/vdb/framework/history-entry/history-entry-extensions.js';
+import { ChevronDown, ChevronUp } from 'lucide-react';
+import { ReactNode, useState } from 'react';
+import { CustomerHistoryCustomerDetail } from '../../../../app/routes/_authenticated/_customers/components/customer-history/customer-history-types.js';
+import { OrderHistoryOrderDetail } from '../../../../app/routes/_authenticated/_orders/components/order-history/order-history-types.js';
+import { HistoryTimeline } from './history-timeline.js';
+
+interface HistoryTimelineWithGroupingProps {
+    historyEntries: HistoryEntryItem[];
+    entity: OrderHistoryOrderDetail | CustomerHistoryCustomerDetail;
+    isPrimaryEvent: (entry: HistoryEntryItem) => boolean;
+    renderEntryContent: (entry: HistoryEntryItem) => ReactNode;
+    children?: ReactNode;
+}
+
+type EntryWithIndex = {
+    entry: HistoryEntryItem;
+    index: number;
+};
+
+export function HistoryTimelineWithGrouping({
+    historyEntries,
+    entity,
+    isPrimaryEvent,
+    renderEntryContent,
+    children,
+}: Readonly<HistoryTimelineWithGroupingProps>) {
+    const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
+
+    // Group consecutive secondary events
+    const groupedEntries: Array<
+        | ({ type: 'primary' } & EntryWithIndex)
+        | {
+              type: 'secondary-group';
+              entries: Array<EntryWithIndex>;
+              startIndex: number;
+          }
+    > = [];
+    let currentGroup: Array<EntryWithIndex> = [];
+
+    for (let i = 0; i < historyEntries.length; i++) {
+        const entry = historyEntries[i];
+        const isSecondary = !isPrimaryEvent(entry);
+
+        if (isSecondary) {
+            currentGroup.push({ entry, index: i });
+        } else {
+            // If we have accumulated secondary events, add them as a group
+            if (currentGroup.length > 0) {
+                groupedEntries.push({
+                    type: 'secondary-group',
+                    entries: currentGroup,
+                    startIndex: currentGroup[0].index,
+                });
+                currentGroup = [];
+            }
+            // Add the primary event
+            groupedEntries.push({ type: 'primary', entry, index: i });
+        }
+    }
+
+    // Don't forget the last group if it exists
+    if (currentGroup.length > 0) {
+        groupedEntries.push({
+            type: 'secondary-group',
+            entries: currentGroup,
+            startIndex: currentGroup[0].index,
+        });
+    }
+
+    const renderEntry = (entry: HistoryEntryItem) => {
+        const CustomType = getCustomHistoryEntryForType(entry.type);
+        if (CustomType) {
+            return <CustomType entry={entry} entity={entity} />;
+        } else {
+            return renderEntryContent(entry);
+        }
+    };
+
+    const toggleGroup = (groupIndex: number) => {
+        const newExpanded = new Set(expandedGroups);
+        if (newExpanded.has(groupIndex)) {
+            newExpanded.delete(groupIndex);
+        } else {
+            newExpanded.add(groupIndex);
+        }
+        setExpandedGroups(newExpanded);
+    };
+
+    return (
+        <div className="">
+            {children && <div className="mb-4">{children}</div>}
+            <HistoryTimeline>
+                {groupedEntries.map((group, groupIndex) => {
+                    if (group.type === 'primary') {
+                        const entry = group.entry;
+                        return <div key={entry.id}>{renderEntry(entry)}</div>;
+                    } else {
+                        // Secondary group
+                        const shouldCollapse = group.entries.length > 2;
+                        const isExpanded = expandedGroups.has(groupIndex);
+                        const visibleEntries =
+                            shouldCollapse && !isExpanded ? group.entries.slice(0, 2) : group.entries;
+
+                        return (
+                            <div key={`group-${groupIndex}`}>
+                                {visibleEntries.map(({ entry }) => (
+                                    <div key={entry.id}>{renderEntry(entry)}</div>
+                                ))}
+                                {shouldCollapse && (
+                                    <div className="flex justify-center py-2">
+                                        <Button
+                                            variant="ghost"
+                                            size="sm"
+                                            onClick={() => toggleGroup(groupIndex)}
+                                            className="text-muted-foreground hover:text-foreground h-6 text-xs"
+                                        >
+                                            {isExpanded ? (
+                                                <>
+                                                    <ChevronUp className="w-3 h-3 mr-1" />
+                                                    Show less
+                                                </>
+                                            ) : (
+                                                <>
+                                                    <ChevronDown className="w-3 h-3 mr-1" />
+                                                    Show all ({group.entries.length - 2})
+                                                </>
+                                            )}
+                                        </Button>
+                                    </div>
+                                )}
+                            </div>
+                        );
+                    }
+                })}
+            </HistoryTimeline>
+        </div>
+    );
+}

+ 26 - 0
packages/dashboard/src/lib/components/shared/history-timeline/use-history-note-editor.ts

@@ -0,0 +1,26 @@
+import { useState } from 'react';
+
+export function useHistoryNoteEditor() {
+    const [noteEditorOpen, setNoteEditorOpen] = useState(false);
+    const [noteState, setNoteState] = useState<{
+        noteId: string;
+        note: string;
+        isPrivate: boolean;
+    }>({
+        noteId: '',
+        note: '',
+        isPrivate: true,
+    });
+
+    const handleEditNote = (noteId: string, note: string, isPrivate: boolean) => {
+        setNoteState({ noteId, note, isPrivate });
+        setNoteEditorOpen(true);
+    };
+
+    return {
+        noteEditorOpen,
+        setNoteEditorOpen,
+        noteState,
+        handleEditNote,
+    };
+}

+ 5 - 0
packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts

@@ -6,6 +6,7 @@ import {
     registerDataTableExtensions,
     registerDetailFormExtensions,
     registerFormComponentExtensions,
+    registerHistoryEntryComponents,
     registerLayoutExtensions,
     registerLoginExtensions,
     registerNavigationExtensions,
@@ -40,6 +41,7 @@ export function executeDashboardExtensionCallbacks() {
  * - Data tables
  * - Detail forms
  * - Login
+ * - Custom history entries
  *
  * @example
  * ```tsx
@@ -83,6 +85,9 @@ export function defineDashboardExtension(extension: DashboardExtension) {
         // Register login extensions
         registerLoginExtensions(extension.login);
 
+        // Register custom history entry components
+        registerHistoryEntryComponents(extension.historyEntries);
+
         // Execute extension source change callbacks
         const callbacks = globalRegistry.get('extensionSourceChangeCallbacks');
         if (callbacks.size) {

+ 7 - 0
packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts

@@ -5,6 +5,7 @@ import {
     DashboardCustomFormComponents,
     DashboardDataTableExtensionDefinition,
     DashboardDetailFormExtensionDefinition,
+    DashboardHistoryEntryComponent,
     DashboardLoginExtensions,
     DashboardNavSectionDefinition,
     DashboardPageBlockDefinition,
@@ -82,4 +83,10 @@ export interface DashboardExtension {
      * Allows you to customize the login page with custom components.
      */
     login?: DashboardLoginExtensions;
+    /**
+     * @description
+     * Allows a custom component to be used to render a history entry item
+     * in the Order or Customer history lists.
+     */
+    historyEntries?: DashboardHistoryEntryComponent[];
 }

+ 24 - 0
packages/dashboard/src/lib/framework/extension-api/logic/history-entries.ts

@@ -0,0 +1,24 @@
+import { DashboardHistoryEntryComponent } from '@/vdb/framework/extension-api/types/index.js';
+
+import { globalRegistry } from '../../registry/global-registry.js';
+
+export function registerHistoryEntryComponents(
+    historyEntryComponents: DashboardHistoryEntryComponent[] = [],
+) {
+    if (historyEntryComponents.length === 0) {
+        return;
+    }
+    globalRegistry.set('historyEntries', entryMap => {
+        for (const entry of historyEntryComponents) {
+            const existingEntry = entryMap.get(entry.type);
+            if (existingEntry) {
+                // eslint-disable-next-line no-console
+                console.warn(
+                    `The history entry type ${entry.type} already has a custom component registered (${String(existingEntry)}`,
+                );
+            }
+            entryMap.set(entry.type, entry.component);
+        }
+        return entryMap;
+    });
+}

+ 1 - 0
packages/dashboard/src/lib/framework/extension-api/logic/index.ts

@@ -3,6 +3,7 @@ export * from './alerts.js';
 export * from './data-table.js';
 export * from './detail-forms.js';
 export * from './form-components.js';
+export * from './history-entries.js';
 export * from './layout.js';
 export * from './login.js';
 export * from './navigation.js';

+ 120 - 0
packages/dashboard/src/lib/framework/extension-api/types/history-entries.ts

@@ -0,0 +1,120 @@
+import React from 'react';
+
+import { CustomerHistoryCustomerDetail } from '../../../../app/routes/_authenticated/_customers/components/customer-history/customer-history-types.js';
+import { OrderHistoryOrderDetail } from '../../../../app/routes/_authenticated/_orders/components/order-history/order-history-types.js';
+
+/**
+ * @description
+ * This object contains the information about the history entry.
+ *
+ * @docsCategory extensions-api
+ * @docsPage HistoryEntries
+ * @since 3.4.3
+ */
+export interface HistoryEntryItem {
+    id: string;
+    /**
+     * @description
+     * The `HistoryEntryType`, such as `ORDER_STATE_TRANSITION`.
+     */
+    type: string;
+    createdAt: string;
+    /**
+     * @description
+     * Whether this entry is visible to customers via the Shop API
+     */
+    isPublic: boolean;
+    /**
+     * @description
+     * If an Administrator created this entry, their details will
+     * be available here.
+     */
+    administrator?: {
+        id: string;
+        firstName: string;
+        lastName: string;
+    } | null;
+    /**
+     * @description
+     * The entry payload data. This will be an object, which is different
+     * for each type of history entry.
+     *
+     * For example, the `CUSTOMER_ADDED_TO_GROUP` data looks like this:
+     * ```json
+     * {
+     *   groupName: 'Some Group',
+     * }
+     * ```
+     *
+     * and the `ORDER_STATE_TRANSITION` data looks like this:
+     * ```json
+     * {
+     *   from: 'ArrangingPayment',
+     *   to: 'PaymentSettled',
+     * }
+     * ```
+     */
+    data: any;
+}
+
+/**
+ * @description
+ * A definition of a custom component that will be used to render the given
+ * type of history entry.
+ *
+ * @example
+ * ```tsx
+ * import { defineDashboardExtension, HistoryEntry } from '\@vendure/dashboard';
+ * import { IdCard } from 'lucide-react';
+ *
+ * defineDashboardExtension({
+ *     historyEntries: [
+ *         {
+ *             type: 'CUSTOMER_TAX_ID_APPROVAL',
+ *             component: ({ entry, entity }) => {
+ *                 return (
+ *                     <HistoryEntry
+ *                         entry={entry}
+ *                         title={'Tax ID verified'}
+ *                         timelineIconClassName={'bg-success text-success-foreground'}
+ *                         timelineIcon={<IdCard />}
+ *                     >
+ *                         <div className="text-xs">Approval reference: {entry.data.ref}</div>
+ *                     </HistoryEntry>
+ *                 );
+ *             },
+ *         },
+ *     ],
+ *  });
+ *  ```
+ *
+ * @docsCategory extensions-api
+ * @docsPage HistoryEntries
+ * @since 3.4.3
+ * @docsWeight 0
+ */
+export interface DashboardHistoryEntryComponent {
+    /**
+     * @description
+     * The `type` should correspond to a valid `HistoryEntryType`, such as
+     *
+     * - `CUSTOMER_REGISTERED`
+     * - `ORDER_STATE_TRANSITION`
+     * - some custom type - see the {@link HistoryService} docs for a guide on
+     *   how to define custom history entry types.
+     */
+    type: string;
+    /**
+     * @description
+     * The component which is used to render the timeline entry. It should use the
+     * {@link HistoryEntry} component and pass the appropriate props to configure
+     * how it will be displayed.
+     *
+     * The `entity` prop will be a subset of the Order object for Order history entries,
+     * or a subset of the Customer object for customer history entries.
+     */
+    component: React.ComponentType<{
+        entry: HistoryEntryItem;
+        entity: OrderHistoryOrderDetail | CustomerHistoryCustomerDetail;
+    }>;
+}

+ 1 - 0
packages/dashboard/src/lib/framework/extension-api/types/index.ts

@@ -3,6 +3,7 @@ export * from './alerts.js';
 export * from './data-table.js';
 export * from './detail-forms.js';
 export * from './form-components.js';
+export * from './history-entries.js';
 export * from './layout.js';
 export * from './login.js';
 export * from './navigation.js';

+ 11 - 0
packages/dashboard/src/lib/framework/history-entry/history-entry-extensions.ts

@@ -0,0 +1,11 @@
+import { DashboardHistoryEntryComponent } from '@/vdb/framework/extension-api/types/index.js';
+
+import { globalRegistry } from '../registry/global-registry.js';
+
+globalRegistry.register('historyEntries', new Map<string, DashboardHistoryEntryComponent['component']>());
+
+export function getCustomHistoryEntryForType(
+    type: string,
+): DashboardHistoryEntryComponent['component'] | undefined {
+    return globalRegistry.get('historyEntries').get(type);
+}

+ 129 - 0
packages/dashboard/src/lib/framework/history-entry/history-entry.tsx

@@ -0,0 +1,129 @@
+import { HistoryEntryItem } from '@/vdb/framework/extension-api/types/index.js';
+import { cn } from '@/vdb/lib/utils.js';
+import React from 'react';
+import { HistoryEntryDate } from '../../components/shared/history-timeline/history-entry-date.js';
+
+/**
+ * @description
+ * The props for the {@link HistoryEntry} component.
+ *
+ * @docsCategory extensions-api
+ * @docsPage HistoryEntries
+ * @since 3.4.3
+ */
+export interface HistoryEntryProps {
+    /**
+     * @description
+     * The entry itself, which will get passed down to your custom component
+     */
+    entry: HistoryEntryItem;
+    /**
+     * @description
+     * The title of the entry
+     */
+    title: string | React.ReactNode;
+    /**
+     * @description
+     * An icon which is used to represent the entry. Note that this will only
+     * display if `isPrimary` is `true`.
+     */
+    timelineIcon?: React.ReactNode;
+    /**
+     * @description
+     * Optional tailwind classes to apply to the icon. For instance
+     *
+     * ```ts
+     * const success = 'bg-success text-success-foreground';
+     * const destructive = 'bg-danger text-danger-foreground';
+     * ```
+     */
+    timelineIconClassName?: string;
+    /**
+     * @description
+     * The name to display of "who did the action". For instance:
+     *
+     * ```ts
+     * const getActorName = (entry: HistoryEntryItem) => {
+     *     if (entry.administrator) {
+     *         return `${entry.administrator.firstName} ${entry.administrator.lastName}`;
+     *     } else if (entity?.customer) {
+     *         return `${entity.customer.firstName} ${entity.customer.lastName}`;
+     *     }
+     *     return '';
+     * };
+     * ```
+     */
+    actorName?: string;
+    children: React.ReactNode;
+    /**
+     * @description
+     * When set to `true`, the timeline entry will feature the specified icon and will not
+     * be collapsible.
+     */
+    isPrimary?: boolean;
+}
+
+/**
+ * @description
+ * A component which is used to display a history entry in the order/customer history timeline.
+ *
+ * @docsCategory extensions-api
+ * @docsPage HistoryEntries
+ * @since 3.4.3
+ */
+export function HistoryEntry({
+    entry,
+    timelineIcon,
+    timelineIconClassName,
+    actorName,
+    title,
+    children,
+    isPrimary = true,
+}: Readonly<HistoryEntryProps>) {
+    return (
+        <div key={entry.id} className="relative group">
+            <div
+                className={`flex gap-3 p-3 rounded-lg hover:bg-muted/30 transition-colors ${!isPrimary ? 'opacity-90' : ''}`}
+            >
+                <div className={cn(`relative z-10 flex-shrink-0`, isPrimary ? 'ml-0' : 'ml-2 mt-1')}>
+                    <div
+                        className={`rounded-full flex items-center justify-center ${isPrimary ? 'h-6 w-6' : 'h-2 w-2 border'} ${timelineIconClassName ?? ''}  ${isPrimary ? 'shadow-sm' : 'shadow-none'}`}
+                    >
+                        <div className={isPrimary ? 'text-current scale-80' : 'text-current scale-0'}>
+                            {timelineIcon ?? ''}
+                        </div>
+                    </div>
+                </div>
+
+                <div className="flex-1 min-w-0">
+                    <div className="flex items-start justify-between">
+                        <div className="flex-1 min-w-0">
+                            <h4
+                                className={`text-sm ${isPrimary ? 'font-medium text-foreground' : 'font-normal text-muted-foreground'}`}
+                            >
+                                {title}
+                            </h4>
+                            <div className="mt-1">{children}</div>
+                        </div>
+
+                        <div className="flex items-center gap-2 ml-4 flex-shrink-0">
+                            <div className="text-right">
+                                <HistoryEntryDate
+                                    date={entry.createdAt}
+                                    className={`text-xs cursor-help ${isPrimary ? 'text-muted-foreground' : 'text-muted-foreground/70'}`}
+                                />
+                                {actorName && (
+                                    <div
+                                        className={`text-xs ${isPrimary ? 'text-muted-foreground' : 'text-muted-foreground/70'}`}
+                                    >
+                                        {actorName}
+                                    </div>
+                                )}
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    );
+}

+ 96 - 9
packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx

@@ -6,17 +6,24 @@ import { Form } from '@/vdb/components/ui/form.js';
 import { useCustomFieldConfig } from '@/vdb/hooks/use-custom-field-config.js';
 import { usePage } from '@/vdb/hooks/use-page.js';
 import { cn } from '@/vdb/lib/utils.js';
-import { useMediaQuery } from '@uidotdev/usehooks';
-import { EllipsisVerticalIcon } from 'lucide-react';
-import React, { ComponentProps, useMemo } from 'react';
+import { useCopyToClipboard, useMediaQuery } from '@uidotdev/usehooks';
+import { CheckIcon, CopyIcon, EllipsisVerticalIcon, InfoIcon } from 'lucide-react';
+import React, { ComponentProps, useMemo, useState } from 'react';
 import { Control, UseFormReturn } from 'react-hook-form';
 
 import { DashboardActionBarItem } from '../extension-api/types/layout.js';
 
 import { Button } from '@/vdb/components/ui/button.js';
-import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/vdb/components/ui/dropdown-menu.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuLabel,
+    DropdownMenuSeparator,
+    DropdownMenuTrigger,
+} from '@/vdb/components/ui/dropdown-menu.js';
 import { PageBlockContext } from '@/vdb/framework/layout-engine/page-block-provider.js';
 import { PageContext, PageContextValue } from '@/vdb/framework/layout-engine/page-provider.js';
+import { Trans } from '@/vdb/lib/trans.js';
 import { getDashboardActionBarItems, getDashboardPageBlocks } from './layout-extensions.js';
 import { LocationWrapper } from './location-wrapper.js';
 
@@ -50,13 +57,13 @@ export interface PageProps extends ComponentProps<'div'> {
  *  - {@link PageTitle}
  *  - {@link PageActionBar}
  *  - {@link PageLayout}
- * 
+ *
  * @example
  * ```tsx
  * import { Page, PageTitle, PageActionBar, PageLayout, PageBlock, Button } from '\@vendure/dashboard';
- * 
+ *
  * const pageId = 'my-page';
- * 
+ *
  * export function MyPage() {
  *  return (
  *    <Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
@@ -319,7 +326,7 @@ export function PageActionBar({ children }: Readonly<{ children: React.ReactNode
 /**
  * @description
  * The PageActionBarLeft component should be used to display the left content of the action bar.
- * 
+ *
  * @docsCategory page-layout
  * @docsPage PageActionBar
  * @since 3.3.0
@@ -330,6 +337,85 @@ export function PageActionBarLeft({ children }: Readonly<{ children: React.React
 
 type InlineDropdownItem = Omit<DashboardActionBarItem, 'type' | 'pageId'>;
 
+function EntityInfoDropdown({ entity }: Readonly<{ entity: any }>) {
+    const [copiedField, setCopiedField] = useState<string | null>(null);
+    const [, copy] = useCopyToClipboard();
+
+    const handleCopy = async (text: string, field: string) => {
+        await copy(text);
+        setCopiedField(field);
+        setTimeout(() => setCopiedField(null), 2000);
+    };
+
+    const formatDate = (dateString: string) => {
+        return new Date(dateString).toLocaleString();
+    };
+
+    if (!entity || !entity.id) {
+        return null;
+    }
+
+    return (
+        <DropdownMenu>
+            <DropdownMenuTrigger asChild>
+                <Button variant="ghost" size="icon" className="text-muted-foreground">
+                    <InfoIcon className="w-4 h-4" />
+                </Button>
+            </DropdownMenuTrigger>
+            <DropdownMenuContent align="end" className="w-64">
+                <DropdownMenuLabel>
+                    <Trans>Entity Information</Trans>
+                </DropdownMenuLabel>
+                <DropdownMenuSeparator />
+                <div className="px-3 py-2">
+                    <div className="flex items-center justify-between">
+                        <span className="text-sm font-medium">ID</span>
+                        <div className="flex items-center gap-1">
+                            <span className="text-sm font-mono">{entity.id}</span>
+                            <button
+                                onClick={() => handleCopy(entity.id, 'id')}
+                                className="p-1 hover:bg-muted rounded-sm transition-colors"
+                            >
+                                {copiedField === 'id' ? (
+                                    <CheckIcon className="h-3 w-3 text-green-500" />
+                                ) : (
+                                    <CopyIcon className="h-3 w-3" />
+                                )}
+                            </button>
+                        </div>
+                    </div>
+                </div>
+                {entity.createdAt && (
+                    <>
+                        <DropdownMenuSeparator />
+                        <div className="px-3 py-2">
+                            <div className="text-sm">
+                                <div className="font-medium text-muted-foreground">
+                                    <Trans>Created</Trans>
+                                </div>
+                                <div className="text-xs">{formatDate(entity.createdAt)}</div>
+                            </div>
+                        </div>
+                    </>
+                )}
+                {entity.updatedAt && (
+                    <>
+                        <DropdownMenuSeparator />
+                        <div className="px-3 py-2">
+                            <div className="text-sm">
+                                <div className="font-medium text-muted-foreground">
+                                    <Trans>Updated</Trans>
+                                </div>
+                                <div className="text-xs">{formatDate(entity.updatedAt)}</div>
+                            </div>
+                        </div>
+                    </>
+                )}
+            </DropdownMenuContent>
+        </DropdownMenu>
+    );
+}
+
 /**
  * @description
  * The PageActionBarRight component should be used to display the right content of the action bar.
@@ -366,6 +452,7 @@ export function PageActionBarRight({
             {actionBarDropdownItems.length > 0 && (
                 <PageActionBarDropdown items={actionBarDropdownItems} page={page} />
             )}
+            <EntityInfoDropdown entity={page.entity} />
         </div>
     );
 }
@@ -449,7 +536,7 @@ export type PageBlockProps = {
  * A component for displaying a block of content on a page. This should be used inside the {@link PageLayout} component.
  * It should be provided with a `column` prop to determine which column it should appear in, and a `blockId` prop
  * to identify the block.
- * 
+ *
  * @example
  * ```tsx
  * <PageBlock column="main" blockId="my-block">

+ 2 - 0
packages/dashboard/src/lib/framework/registry/registry-types.ts

@@ -1,6 +1,7 @@
 import {
     BulkAction,
     DashboardActionBarItem,
+    DashboardHistoryEntryComponent,
     DashboardLoginExtensions,
     DashboardPageBlockDefinition,
     DashboardWidgetDefinition,
@@ -26,4 +27,5 @@ export interface GlobalRegistryContents {
     listQueryDocumentRegistry: Map<string, DocumentNode[]>;
     detailQueryDocumentRegistry: Map<string, DocumentNode[]>;
     loginExtensions: DashboardLoginExtensions;
+    historyEntries: Map<string, DashboardHistoryEntryComponent['component']>;
 }

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

@@ -44,6 +44,7 @@ export * from './components/data-table/filters/data-table-string-filter.js';
 export * from './components/data-table/human-readable-operator.js';
 export * from './components/data-table/refresh-button.js';
 export * from './components/data-table/types.js';
+export * from './components/data-table/use-all-bulk-actions.js';
 export * from './components/data-table/use-generated-columns.js';
 export * from './components/labeled-data.js';
 export * from './components/layout/app-layout.js';
@@ -95,10 +96,10 @@ export * from './components/shared/facet-value-chip.js';
 export * from './components/shared/facet-value-selector.js';
 export * from './components/shared/form-field-wrapper.js';
 export * from './components/shared/history-timeline/history-entry-date.js';
-export * from './components/shared/history-timeline/history-entry.js';
 export * from './components/shared/history-timeline/history-note-checkbox.js';
 export * from './components/shared/history-timeline/history-note-editor.js';
 export * from './components/shared/history-timeline/history-note-input.js';
+export * from './components/shared/history-timeline/history-timeline-with-grouping.js';
 export * from './components/shared/history-timeline/history-timeline.js';
 export * from './components/shared/icon-mark.js';
 export * from './components/shared/language-selector.js';
@@ -110,6 +111,12 @@ export * from './components/shared/paginated-list-data-table.js';
 export * from './components/shared/permission-guard.js';
 export * from './components/shared/product-variant-selector.js';
 export * from './components/shared/remove-from-channel-bulk-action.js';
+export * from './components/shared/rich-text-editor/image-dialog.js';
+export * from './components/shared/rich-text-editor/link-dialog.js';
+export * from './components/shared/rich-text-editor/responsive-toolbar.js';
+export * from './components/shared/rich-text-editor/rich-text-editor.js';
+export * from './components/shared/rich-text-editor/table-delete-menu.js';
+export * from './components/shared/rich-text-editor/table-edit-icons.js';
 export * from './components/shared/role-code-label.js';
 export * from './components/shared/role-selector.js';
 export * from './components/shared/seller-selector.js';
@@ -191,6 +198,7 @@ export * from './framework/extension-api/logic/alerts.js';
 export * from './framework/extension-api/logic/data-table.js';
 export * from './framework/extension-api/logic/detail-forms.js';
 export * from './framework/extension-api/logic/form-components.js';
+export * from './framework/extension-api/logic/history-entries.js';
 export * from './framework/extension-api/logic/layout.js';
 export * from './framework/extension-api/logic/login.js';
 export * from './framework/extension-api/logic/navigation.js';
@@ -199,6 +207,7 @@ export * from './framework/extension-api/types/alerts.js';
 export * from './framework/extension-api/types/data-table.js';
 export * from './framework/extension-api/types/detail-forms.js';
 export * from './framework/extension-api/types/form-components.js';
+export * from './framework/extension-api/types/history-entries.js';
 export * from './framework/extension-api/types/layout.js';
 export * from './framework/extension-api/types/login.js';
 export * from './framework/extension-api/types/navigation.js';
@@ -215,6 +224,8 @@ export * from './framework/form-engine/overridden-form-component.js';
 export * from './framework/form-engine/use-generated-form.js';
 export * from './framework/form-engine/utils.js';
 export * from './framework/form-engine/value-transformers.js';
+export * from './framework/history-entry/history-entry-extensions.js';
+export * from './framework/history-entry/history-entry.js';
 export * from './framework/layout-engine/dev-mode-button.js';
 export * from './framework/layout-engine/layout-extensions.js';
 export * from './framework/layout-engine/location-wrapper.js';

+ 1 - 1
packages/dev-server/graphql/graphql-env.d.ts

@@ -510,4 +510,4 @@ declare module 'gql.tada' {
   interface setupSchema {
     introspection: introspection
   }
-}
+}