@mercurjs/docs 2.3.6-canary.0 → 2.3.6-canary.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -116,7 +116,7 @@ The 1.x `OrderSet` entity is renamed to `OrderGroup` in 2.x. This is a breaking
116
116
 
117
117
  Two fields were removed from the group:
118
118
 
119
- - **`payment_collection_id`**: payment collections are now linked at the individual order level. Query the linked orders to get the payment collection.
119
+ - **`payment_collection_id`**: orders share the cart's payment collection until the payment is captured, then each gets its own. Read `payment_collections` on the orders (see [Order Groups](/learn/order-groups)).
120
120
  - **`sales_channel_id`**: the sales channel is stored on each individual order.
121
121
 
122
122
  Two fields are now computed at query time rather than stored: **`seller_count`** (distinct sellers across linked orders) and **`total`** (sum of order totals).
@@ -54,16 +54,39 @@ Order groups are created automatically during checkout by the `completeCartWithS
54
54
  - Each seller and the customer (if new relationship)
55
55
  6. **Finalize** — Inventory is reserved, payment is authorized, and the `order_group.created` event is emitted
56
56
 
57
- <Info>
58
- A single payment collection is shared across all of a cart's split orders and stays on the **cart**, not on each order. Split orders are **not** linked directly to the payment collection (Medusa's `order ↔ payment_collection` link is one-to-one on the payment-collection side). Read the payment collection through the cart:
57
+ ## Payments
58
+
59
+ The customer pays for the cart once: one payment collection on the cart, one payment at the payment provider. Medusa links a payment collection to a single order, so the orders of a cart share that payment through the cart until it is captured:
60
+
61
+ ```text
62
+ order → cart → payment_collection → payment
63
+ ```
64
+
65
+ When the cart payment is captured, it is split. Every order gets a payment collection of its own, linked to the order, with a captured payment for the order's share. That payment is on the same provider as the cart payment and points at the same charge. Shares are proportional to the order totals, rounded to the currency precision, and sum exactly to the captured amount.
59
66
 
60
- ```text
61
- order → cart → payment_collection
62
- ```
67
+ ```text
68
+ order → payment_collections → payment
69
+ ```
63
70
 
64
- Order-group and vendor order reads normalize `cart.payment_collection` back onto `payment_collections` and recompute `payment_status` from it, so consumers keep the familiar shape. When requesting fields on vendor order routes, make sure the request does not replace route defaults that already include `cart.payment_collection.*` (a `fields=` list containing bare fields replaces defaults in Medusa; use `+`-prefixed fields to merge, or include the `cart.payment_collection.*` paths explicitly).
71
+ From then on Medusa's order and payment workflows and the stock admin and vendor routes work on the order unchanged.
72
+
73
+ | Moment | What happens |
74
+ |--------|--------------|
75
+ | Cancel an order before capture | No payment is touched: the authorization is still needed by the cart's other orders. Once every order of the cart is canceled, the authorization is voided. |
76
+ | Capture | The cart payment is captured as a whole, by the provider webhook or from the dashboard, and split between the orders. What was charged for orders canceled earlier is refunded. |
77
+ | Refund an order's payment | The amount is refunded at the provider against the customer's charge and recorded on that order. It cannot exceed the order's share. |
78
+ | Cancel an order after capture | The order's share is refunded. |
79
+
80
+ Order reads expose whichever applies under `payment_collections`: the cart's collection before the capture, the order's own afterwards.
81
+
82
+ <Info>
83
+ The split, and voiding the authorization after the last cancel, run in subscribers to `payment.captured` and `order.canceled`, shortly after the request returns. A provider that captures on authorization is split during checkout.
65
84
  </Info>
66
85
 
86
+ <Note>
87
+ Orders whose payment was captured before this model was introduced are not split retroactively and keep the cart's shared collection.
88
+ </Note>
89
+
67
90
  ## Events
68
91
 
69
92
  | Event | Payload | When |
@@ -155,7 +155,8 @@ curl -X POST http://localhost:9000/vendor/payout-accounts/<account-id>/onboardin
155
155
  -H "Authorization: Bearer <seller-token>" \
156
156
  -H "Content-Type: application/json" \
157
157
  -d '{
158
- "context": {
158
+ "data": {
159
+ "refresh_url": "https://my-store.com/settings/payouts",
159
160
  "return_url": "https://my-store.com/settings/payouts"
160
161
  }
161
162
  }'
@@ -15,11 +15,11 @@ Creates an onboarding session with the payout provider. For Stripe Connect, the
15
15
  ## Body parameters
16
16
 
17
17
  <ParamField body="data" type="object">
18
- Provider-specific data forwarded when creating the onboarding.
18
+ Provider-specific data forwarded when creating the onboarding (e.g. `refresh_url` and `return_url` for Stripe Connect). Merged over the payout account's stored `data`.
19
19
  </ParamField>
20
20
 
21
21
  <ParamField body="context" type="object">
22
- Provider-specific context (e.g. `refresh_url` and `return_url` for Stripe Connect).
22
+ Additional context forwarded to the provider, such as an `idempotency_key`.
23
23
  </ParamField>
24
24
 
25
25
  ## Response
@@ -40,13 +40,13 @@ curl -X POST 'http://localhost:9000/vendor/payout-accounts/pacc_01HXYZ/onboardin
40
40
  -H 'Authorization: Bearer <token>' \
41
41
  -H 'x-seller-id: <seller_id>' \
42
42
  -H 'Content-Type: application/json' \
43
- -d '{"context": {"refresh_url": "https://vendor.example.com/payouts", "return_url": "https://vendor.example.com/payouts"}}'
43
+ -d '{"data": {"refresh_url": "https://vendor.example.com/payouts", "return_url": "https://vendor.example.com/payouts"}}'
44
44
  ```
45
45
 
46
46
  ```ts JS Client
47
47
  const { onboarding } = await client.vendor.payoutAccounts.$id.onboarding.mutate({
48
48
  $id: "pacc_01HXYZ",
49
- context: {
49
+ data: {
50
50
  refresh_url: "https://vendor.example.com/payouts",
51
51
  return_url: "https://vendor.example.com/payouts",
52
52
  },
@@ -60,7 +60,6 @@ const { onboarding } = await client.vendor.payoutAccounts.$id.onboarding.mutate(
60
60
  "onboarding": {
61
61
  "id": "onb_01HXYZ",
62
62
  "data": { "url": "https://connect.stripe.com/setup/s/..." },
63
- "context": { "return_url": "https://vendor.example.com/payouts" },
64
63
  "created_at": "2026-01-15T10:00:00.000Z"
65
64
  }
66
65
  }
@@ -9,11 +9,11 @@ Creates a payout account for the current seller through the configured payout pr
9
9
  ## Body parameters
10
10
 
11
11
  <ParamField body="data" type="object">
12
- Provider-specific data forwarded when creating the account.
12
+ Provider-specific data forwarded when creating the account (e.g. `{ "country": "US" }` for Stripe Connect). Defaults to `{}`.
13
13
  </ParamField>
14
14
 
15
15
  <ParamField body="context" type="object">
16
- Provider-specific context (e.g. `{ "country": "US" }` for Stripe Connect).
16
+ Additional context forwarded to the provider, such as an `idempotency_key`.
17
17
  </ParamField>
18
18
 
19
19
  ## Response
@@ -35,12 +35,12 @@ curl -X POST 'http://localhost:9000/vendor/payout-accounts' \
35
35
  -H 'Authorization: Bearer <token>' \
36
36
  -H 'x-seller-id: <seller_id>' \
37
37
  -H 'Content-Type: application/json' \
38
- -d '{"context": {"country": "US"}}'
38
+ -d '{"data": {"country": "US"}}'
39
39
  ```
40
40
 
41
41
  ```ts JS Client
42
42
  const { payout_account } = await client.vendor.payoutAccounts.mutate({
43
- context: { country: "US" },
43
+ data: { country: "US" },
44
44
  })
45
45
  ```
46
46
  </RequestExample>
@@ -51,7 +51,7 @@ const { payout_account } = await client.vendor.payoutAccounts.mutate({
51
51
  "payout_account": {
52
52
  "id": "pacc_01HXYZ",
53
53
  "status": "pending",
54
- "context": { "country": "US" },
54
+ "data": { "id": "acct_1Q2w3E4r5T6y7U8i" },
55
55
  "created_at": "2026-01-15T10:00:00.000Z"
56
56
  }
57
57
  }
@@ -101,6 +101,7 @@ The `config` object controls the page's sidebar entry.
101
101
  | `icon` | `ComponentType` (optional) | Sidebar icon, from `@medusajs/icons`. |
102
102
  | `rank` | `number` (optional) | Order among sibling items. Lower ranks first. |
103
103
  | `nested` | `string` (optional) | Parent item id to nest this entry under. |
104
+ | `group` | `string` (optional) | Sidebar group to list the entry under, as a string literal. See [Groups](#groups). |
104
105
  | `translationNs` | `string` (optional) | i18n namespace used to resolve `label` as a translation key. |
105
106
  | `public` | `boolean` (optional) | Render the route before authentication under the public layout. |
106
107
 
@@ -131,7 +132,7 @@ Read the loader's result with `useLoaderData()` inside the component.
131
132
 
132
133
  ## Reshape built-in navigation
133
134
 
134
- Use `defineNavigationConfig` to reorder, hide, relabel, or re-parent built-in
135
+ Use `defineNavigationConfig` to reorder, hide, relabel, re-parent, or group built-in
135
136
  sidebar items. It lives in a single host-owned file, `src/_navigation.ts`. Blocks
136
137
  cannot contribute navigation overrides.
137
138
 
@@ -159,6 +160,49 @@ Each entry is a `NavItemOverride`.
159
160
  | `label` | `string` (optional) | i18n key or literal replacing the item's label. |
160
161
  | `icon` | `ComponentType` (optional) | Icon component replacing the item's icon, from `@medusajs/icons`. |
161
162
  | `nested` | `NavParentId \| null` (optional) | Re-parent under a built-in parent id. `null` promotes a nested item to top level. |
163
+ | `group` | `NavGroupId` (optional) | List the item under a group declared in `groups`, or a built-in settings group. |
164
+
165
+ ### Groups
166
+
167
+ `groups` declares group headings by keyword, and an item's `group` assigns it to
168
+ one. A group renders in whichever sidebar its items live in.
169
+
170
+ The main sidebar has no built-in groups. Top-level items assigned to a declared
171
+ group render below the ungrouped list, under the group's heading. The settings
172
+ sidebar ships `general`, `developer`, and `myAccount` in the admin panel and
173
+ `general` in the vendor portal, which `groups` can also override by id. Settings
174
+ items are targeted as `settings/<segment>`.
175
+
176
+ ```ts apps/admin/src/_navigation.ts
177
+ import { defineNavigationConfig } from "@mercurjs/dashboard-sdk"
178
+
179
+ export default defineNavigationConfig({
180
+ groups: [
181
+ { id: "catalog", label: "Catalog" },
182
+ { id: "integrations", label: "Integrations", rank: 1 },
183
+ { id: "developer", hidden: true },
184
+ ],
185
+ items: [
186
+ { id: "products", group: "catalog" },
187
+ { id: "inventory", group: "catalog" },
188
+ { id: "settings/tax-regions", group: "integrations" },
189
+ ],
190
+ })
191
+ ```
192
+
193
+ Each entry is a `NavGroupConfig`.
194
+
195
+ | Field | Type | Description |
196
+ | --------------- | -------------------- | --------------------------------------------------------------------------- |
197
+ | `id` | `string` | A new keyword that declares a group, or a built-in settings group id to override. |
198
+ | `label` | `string` (optional) | Heading text. A literal, or an i18n key with `translationNs`. Defaults to the `id`. |
199
+ | `translationNs` | `string` (optional) | i18n namespace used to resolve `label`. |
200
+ | `rank` | `number` (optional) | Order among groups, lower first. Defaults to declaration order. Built-in settings groups rank `0`, `1`, `2`. |
201
+ | `hidden` | `boolean` (optional) | Remove the group and its items from the sidebar. |
202
+
203
+ A custom page joins a group through `config.group`. An undeclared group falls
204
+ back to the default placement (the ungrouped list, or the first settings group),
205
+ and a group with no visible items isn't rendered.
162
206
 
163
207
  Navigation overrides reshape existing items only. To add a new item, register a
164
208
  page with a `config.label` as shown above.
@@ -70,6 +70,7 @@ finds it at build time. There is no manifest to edit and no route to register.
70
70
  | ------ | -------------------------------- | ----------------------------------------------------------------------- |
71
71
  | `zone` | `WidgetZoneId \| WidgetZoneId[]` | The zone or zones to render in. Multiple widgets in one zone stack in registration order. |
72
72
  | `id` | `string` (optional) | A stable id. Derived from the file path at build time when omitted. |
73
+ | `label` | `string` (optional) | The tab label, as an i18n key or a literal. Only used by [tab zones](#tabs). |
73
74
 
74
75
  ## Component props
75
76
 
@@ -93,6 +94,89 @@ const ProductDetailNote = ({ data }: { data?: HttpTypes.AdminProduct }) => (
93
94
  authentication and receive no `data`.
94
95
  </Note>
95
96
 
97
+ ## Tabs
98
+
99
+ Some detail pages group their content into tabs. A tab zone lets a widget add a
100
+ tab of its own: the widget's `label` names the tab and its component renders as
101
+ the tab's panel. The placement decides whether the tab sits before or after the
102
+ built-in ones.
103
+
104
+ ```tsx apps/admin-test/src/widgets/store-loyalty-tab.tsx
105
+ import { defineWidgetConfig } from "@mercurjs/dashboard-sdk"
106
+ import { Container, Heading } from "@medusajs/ui"
107
+ import type { SellerDTO } from "@mercurjs/types"
108
+
109
+ const StoreLoyaltyTab = ({ data }: { data?: SellerDTO }) => (
110
+ <Container>
111
+ <Heading level="h2">Loyalty for {data?.name}</Heading>
112
+ </Container>
113
+ )
114
+
115
+ export const config = defineWidgetConfig({
116
+ zone: "stores.detail.tabs.after",
117
+ id: "loyalty",
118
+ label: "Loyalty",
119
+ })
120
+
121
+ export default StoreLoyaltyTab
122
+ ```
123
+
124
+ A widget that targets a tab zone without a `label` is not shown. `permission`
125
+ works as on any other widget and hides the tab from actors who lack it.
126
+
127
+ | Panel | Tab zone | Page |
128
+ | ----- | ------------------- | ------------------------------------------------------- |
129
+ | Admin | `stores.detail.tabs` | Store detail, next to Orders, Offers, Users and Time off. |
130
+
131
+ ## List item rows
132
+
133
+ The locations list renders one card per stock location. The `locations.list.item`
134
+ zone sits inside each card, so a widget there adds a row to the card instead of a
135
+ separate block above or below the list. `locations.list.item.before` renders
136
+ between the card header and the built-in rows, and `locations.list.item.after`
137
+ renders after them. `data` is the stock location of that card.
138
+
139
+ ```tsx apps/vendor/src/widgets/location-local-delivery-row.tsx
140
+ import { defineWidgetConfig } from "@mercurjs/dashboard-sdk"
141
+ import { StatusBadge, Text } from "@medusajs/ui"
142
+ import type { HttpTypes } from "@mercurjs/types"
143
+
144
+ type StockLocation = HttpTypes.VendorStockLocationResponse["stock_location"]
145
+
146
+ const LocationLocalDeliveryRow = ({ data }: { data?: StockLocation }) => {
147
+ const enabled = data?.fulfillment_sets?.some(
148
+ (set) => set.type === "local_delivery"
149
+ )
150
+
151
+ return (
152
+ <div className="flex items-center justify-between px-6 py-4">
153
+ <Text size="small" weight="plus" className="text-ui-fg-subtle flex-1">
154
+ Local delivery
155
+ </Text>
156
+ <div className="flex-1">
157
+ <StatusBadge color={enabled ? "green" : "grey"}>
158
+ {enabled ? "Enabled" : "Disabled"}
159
+ </StatusBadge>
160
+ </div>
161
+ </div>
162
+ )
163
+ }
164
+
165
+ export const config = defineWidgetConfig({
166
+ zone: "locations.list.item.after",
167
+ })
168
+
169
+ export default LocationLocalDeliveryRow
170
+ ```
171
+
172
+ Return a single row element, without a `Container` of its own. The card draws the
173
+ divider between rows.
174
+
175
+ | Panel | Zone | Page |
176
+ | ------ | --------------------- | -------------------------------------- |
177
+ | Vendor | `locations.list.item` | Settings → Locations, each location card. |
178
+ | Admin | `locations.list.item` | Settings → Locations, each location card. |
179
+
96
180
  ## Zone ids
97
181
 
98
182
  A zone id reads `<domain>.<view>.<placement>`.
@@ -120,7 +204,7 @@ expands to a `.before` and an `.after` zone.
120
204
  | `customer-groups` | ✓ | ✓ | | |
121
205
  | `customers` | ✓ | ✓ | ✓ | |
122
206
  | `inventory` | ✓ | ✓ | ✓ | |
123
- | `locations` | ✓ | ✓ | ✓ | |
207
+ | `locations` | ✓ | ✓ | ✓ | `list.item` |
124
208
  | `offer-variants` | | ✓ | ✓ | |
125
209
  | `offers` | ✓ | ✓ | ✓ | |
126
210
  | `orders` | ✓ | ✓ | ✓ | `detail.summary` |
@@ -167,7 +251,7 @@ cell expands to a `.before` and an `.after` zone.
167
251
  | `customer-groups` | ✓ | ✓ | | |
168
252
  | `customers` | ✓ | ✓ | ✓ | |
169
253
  | `inventory` | ✓ | ✓ | ✓ | |
170
- | `locations` | ✓ | ✓ | ✓ | |
254
+ | `locations` | ✓ | ✓ | ✓ | `list.item` |
171
255
  | `marketplace` | | ✓ | | |
172
256
  | `offer-variants` | | ✓ | ✓ | |
173
257
  | `offers` | ✓ | ✓ | ✓ | |
@@ -151,6 +151,7 @@ export default defineNavigationConfig({
151
151
 
152
152
  - `id` targets any built-in item, top-level routes or nested children, by its stable id, typed as `NavItemId`.
153
153
  - `rank` orders an item within its parent, `hidden` removes it from the sidebar, `label` and `icon` relabel it, and `nested` re-parents it (`nested: null` promotes to top level, typed against `NavParentId`).
154
+ - `groups` declares group headings by keyword, and an item's `group` lists it under one, in the main sidebar or the settings sidebar (settings items are targeted as `settings/<segment>`). The built-in settings groups can be reshaped by id. A custom page picks its group with `group` in its route config.
154
155
  - Navigation is a single host-owned file. Installed blocks cannot reorder the sidebar, so it stays one source of truth. It does not change custom routes, which still place themselves via `defineRouteConfig`.
155
156
 
156
157
  Walk through it in [Customize navigation](/rc/resources/tutorials/customize-navigation).
@@ -242,6 +243,7 @@ For the full walkthrough (forms, sections, and list columns), see [Extend forms
242
243
  | `icon` | `ComponentType` | Icon component (for example from `@medusajs/icons`) |
243
244
  | `rank` | `number` | Sort order (lower numbers appear first) |
244
245
  | `nested` | `string` | Parent path for nested menu items |
246
+ | `group` | `string` | Sidebar group declared in `_navigation.ts` to list the item under |
245
247
  | `translationNs` | `string` | i18n namespace for the label |
246
248
  | `public` | `boolean` | If `true`, the route is accessible without authentication |
247
249
 
@@ -270,6 +272,51 @@ File paths map to URL routes automatically.
270
272
  | `src/routes/(settings)/page.tsx` | Route grouping | Groups routes without adding a URL segment |
271
273
  | `src/routes/dashboard/@sidebar/page.tsx` | Parallel route | Renders alongside parent |
272
274
 
275
+ ### Adding permissions from a plugin
276
+
277
+ A plugin that ships its own pages usually needs its own permission keys. Declare them on the server, then register the same keys with the dashboard SDK so pages can be guarded and hidden from navigation.
278
+
279
+ **1. Define the keys on the server.** Import the definitions file once (for example from your module's `index.ts`) so the keys join the catalog:
280
+
281
+ ```typescript src/permissions.ts
282
+ import { defineMercurPermissions } from "@mercurjs/core/permissions/registry"
283
+
284
+ export const messagingPermissions = defineMercurPermissions([
285
+ { key: "messaging", group: "messaging", surface: ["vendor"], rights: ["view", "edit", "manage"] },
286
+ ])
287
+ ```
288
+
289
+ Guard the plugin's API routes with the same key (`requirePermission("messaging", "view")`).
290
+
291
+ **2. Register the keys with the dashboard SDK.** `PermissionKey` is built from the `PermissionKeys` interface, so augment it:
292
+
293
+ ```typescript src/permissions.d.ts
294
+ declare module "@mercurjs/dashboard-sdk" {
295
+ interface PermissionKeys {
296
+ messaging: true
297
+ }
298
+ }
299
+
300
+ export {}
301
+ ```
302
+
303
+ The key now type-checks everywhere the SDK accepts a permission:
304
+
305
+ ```tsx src/routes/messages/page.tsx
306
+ import type { RouteConfig, RouteHandle } from "@mercurjs/dashboard-sdk"
307
+
308
+ export const config: RouteConfig = {
309
+ label: "Messages",
310
+ permissions: ["messaging:view"],
311
+ }
312
+
313
+ export const handle: RouteHandle = {
314
+ permissions: "messaging:view",
315
+ }
316
+ ```
317
+
318
+ `can("messaging", "edit")` and `buildPermission("messaging", "manage")` accept it too. This only affects types: the dashboard reads the member's effective rights from the API at runtime.
319
+
273
320
  ## Branding
274
321
 
275
322
  Set `name` and `logo` in the plugin options to customize the sidebar header.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: "How to Customize Navigation"
3
3
  sidebarTitle: "Customize Navigation"
4
- description: "Reorder, hide, relabel, and re-parent the panel's built-in sidebar items from a single _navigation.ts file."
4
+ description: "Reorder, hide, relabel, re-parent, and group the panel's built-in sidebar items from a single _navigation.ts file."
5
5
  ---
6
6
 
7
7
  Reshape the built-in sidebar without replacing it. You author one host-owned file, `src/_navigation.ts`, and it reorders, hides, relabels, and re-parents the built-in items.
@@ -55,8 +55,9 @@ With it present, `id` and `nested` autocomplete and an unknown id fails `tsc`.
55
55
  | `label` | `string` | Relabel with an i18n key or literal. |
56
56
  | `icon` | `ComponentType` | Replace its icon. |
57
57
  | `nested` | `NavParentId \| null` | Re-parent under another top-level item. `null` promotes a nested item to top level. |
58
+ | `group` | `NavGroupId` | List the item under a [group heading](#group-items-under-headings). |
58
59
 
59
- Both `id` and `nested` are checked against the panel's generated `NavItemRegistry` and `NavParentRegistry`.
60
+ `id`, `nested`, and `group` are checked against the panel's generated `NavItemRegistry`, `NavParentRegistry`, and `NavGroupRegistry`.
60
61
  </Step>
61
62
  <Step title="Reload the panel">
62
63
  Open the vendor portal. Orders sits at the top, Price Lists is gone from the menu, and Campaigns now appears under Orders.
@@ -80,6 +81,80 @@ export default defineNavigationConfig({
80
81
  })
81
82
  ```
82
83
 
84
+ ## Group items under headings
85
+
86
+ Declare groups by keyword in `groups`, then assign items to them with `group`. A group renders in whichever sidebar its items live in, and a group with no visible items isn't rendered.
87
+
88
+ ### Main sidebar
89
+
90
+ The main sidebar ships without groups. Items you assign to a declared group move out of the default list and render below it, under the group's heading.
91
+
92
+ ```ts apps/vendor/src/_navigation.ts
93
+ import { defineNavigationConfig } from "@mercurjs/dashboard-sdk"
94
+
95
+ export default defineNavigationConfig({
96
+ groups: [
97
+ { id: "catalog", label: "Catalog" },
98
+ { id: "finance", label: "Finance" },
99
+ ],
100
+ items: [
101
+ { id: "products", group: "catalog" },
102
+ { id: "inventory", group: "catalog" },
103
+ { id: "payouts", group: "finance" },
104
+ ],
105
+ })
106
+ ```
107
+
108
+ Only top-level items can be grouped. Nested children follow their parent.
109
+
110
+ ### Settings sidebar
111
+
112
+ The settings sidebar already lists its items under groups. The admin panel ships `general`, `developer`, and `myAccount`. The vendor portal ships `general`. You can reshape these built-in groups, add your own, and move items between them. Settings items are targeted by their path without the leading slash, such as `settings/users`.
113
+
114
+ ```ts apps/admin/src/_navigation.ts
115
+ import { defineNavigationConfig } from "@mercurjs/dashboard-sdk"
116
+
117
+ export default defineNavigationConfig({
118
+ groups: [
119
+ { id: "integrations", label: "Integrations", rank: 1 }, // new group, after General
120
+ { id: "developer", label: "API" }, // relabel a built-in group
121
+ ],
122
+ items: [
123
+ { id: "settings/secret-api-keys", group: "integrations" },
124
+ { id: "settings/tax-regions", group: "integrations", rank: 0 },
125
+ ],
126
+ })
127
+ ```
128
+
129
+ ### Group fields
130
+
131
+ | Field | Type | Effect |
132
+ |-------|------|--------|
133
+ | `id` | `string` | **Required.** A new keyword to declare a group, or a built-in settings group id to override. |
134
+ | `label` | `string` | Heading text. A literal, or an i18n key when `translationNs` is set. Defaults to the `id`. |
135
+ | `translationNs` | `string` | i18n namespace used to resolve `label`. |
136
+ | `rank` | `number` | Order among groups, lower first. Defaults to declaration order. Built-in settings groups rank `0`, `1`, `2`. |
137
+ | `hidden` | `boolean` | Remove the group and its items from the sidebar. |
138
+
139
+ An item's `group` only accepts a built-in group id or an id declared in the same file's `groups`, so a typo fails `tsc`.
140
+
141
+ ### Group a custom page
142
+
143
+ A custom page picks its group in its own route config with the same keyword.
144
+
145
+ ```tsx apps/admin/src/routes/settings/webhooks/page.tsx
146
+ import type { RouteConfig } from "@mercurjs/dashboard-sdk"
147
+
148
+ export const config: RouteConfig = {
149
+ label: "Webhooks",
150
+ group: "integrations",
151
+ }
152
+ ```
153
+
154
+ <Note>
155
+ A page whose group isn't declared falls back to the default placement: the ungrouped list in the main sidebar, or the first group in the settings sidebar.
156
+ </Note>
157
+
83
158
  ## Verify
84
159
 
85
160
  1. The top-level order reflects your `rank` values, with `orders` first.
@@ -94,6 +169,9 @@ export default defineNavigationConfig({
94
169
  <Accordion title="Can an installed block reorder the sidebar?">
95
170
  No. Navigation is deliberately host-only. Blocks can ship pages, widgets, and custom fields, but the sidebar order stays a single source of truth in your app's `_navigation.ts`.
96
171
  </Accordion>
172
+ <Accordion title="Can a block put its page in a group?">
173
+ Yes. A block's page sets `group` in its route config like any other page. If the host hasn't declared that group, the page falls back to the default placement.
174
+ </Accordion>
97
175
  <Accordion title="What ids can I target?">
98
176
  Any built-in item, top-level or nested, by its own id, such as `orders`, `products`, `categories`, `collections`, `campaigns`, or `customer-groups`. Let your editor autocomplete `id:` against `NavItemId`. The full set is generated into your panel's `extension-targets.d.ts`.
99
177
  </Accordion>
package/llms.txt CHANGED
@@ -51,7 +51,7 @@ package (`node_modules/@mercurjs/docs/`).
51
51
  - [How to Build a Block](content/resources/tutorials/build-a-block.mdx) — Author a reusable feature as a block covering backend, panel UI, and docs, then build it into a registry and install it into any Mercur project.
52
52
  - [How to Add a Custom API Route](content/resources/tutorials/custom-api-route.mdx) — Add a backend endpoint, regenerate the route map, and call it from a panel page with full type safety.
53
53
  - [How to Create a Custom Page](content/resources/tutorials/custom-panel-page.mdx) — Add a page to the vendor portal or admin panel with file-based routing and let the dashboard SDK wire it into the sidebar.
54
- - [How to Customize Navigation](content/resources/tutorials/customize-navigation.mdx) — Reorder, hide, relabel, and re-parent the panel's built-in sidebar items from a single _navigation.ts file.
54
+ - [How to Customize Navigation](content/resources/tutorials/customize-navigation.mdx) — Reorder, hide, relabel, re-parent, and group the panel's built-in sidebar items from a single _navigation.ts file.
55
55
  - [How to Extend Forms and Tables](content/resources/tutorials/extend-forms-and-tables.mdx) — Add validated fields, detail-section displays, and list columns to a built-in model from a single file with defineCustomFieldsConfig.
56
56
  - [How to Extend Onboarding](content/resources/tutorials/extend-onboarding.mdx) — Add a field to the vendor store-setup surface, carry it through additional_data, and persist it from a workflow hook without forking core.
57
57
  - [How to Create Master Products and Offers](content/resources/tutorials/master-products-and-offers.mdx) — Publish one master product, add two competing seller offers, and read per-offer prices from the Store API.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercurjs/docs",
3
- "version": "2.3.6-canary.0",
3
+ "version": "2.3.6-canary.10",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/mercurjs/mercur",