@mercurjs/docs 2.2.0-rc.0 → 2.2.0-rc.2
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.
- package/content/learn/concepts.mdx +1 -1
- package/content/references/overview.mdx +10 -0
- package/content/references/panel-extension-api.mdx +337 -0
- package/content/references/workflows/cart/add-seller-shipping-method-to-cart.mdx +5 -1
- package/content/references/workflows/commission/batch-commission-rules.mdx +6 -2
- package/content/references/workflows/offer/batch-offer-inventory-items.mdx +6 -2
- package/content/references/workflows/offer/create-offers.mdx +3 -1
- package/content/references/workflows/offer/update-offers.mdx +3 -1
- package/content/references/workflows/order-group/get-order-groups-list.mdx +3 -1
- package/content/references/workflows/payout/process-payout-for-webhook.mdx +3 -1
- package/content/references/workflows/product/confirm-products.mdx +2 -0
- package/content/references/workflows/product/reject-product.mdx +2 -0
- package/content/references/workflows/product/request-product-change.mdx +2 -0
- package/content/references/workflows/seller/approve-seller.mdx +2 -0
- package/content/references/workflows/seller/suspend-seller.mdx +2 -0
- package/content/references/workflows/seller/terminate-seller.mdx +2 -0
- package/content/references/workflows/seller/unsuspend-seller.mdx +2 -0
- package/content/references/workflows/seller/unterminate-seller.mdx +2 -0
- package/content/resources/best-practices/api-routes.mdx +306 -0
- package/content/resources/best-practices/custom-fields.mdx +241 -0
- package/content/resources/best-practices/frontend.mdx +286 -0
- package/content/resources/best-practices/module-links.mdx +157 -0
- package/content/resources/best-practices/modules.mdx +144 -0
- package/content/resources/best-practices/overview.mdx +115 -0
- package/content/resources/best-practices/subscribers-and-jobs.mdx +133 -0
- package/content/resources/best-practices/types.mdx +120 -0
- package/content/resources/best-practices/workflows.mdx +173 -0
- package/content/resources/customization/custom-fields.mdx +5 -1
- package/content/resources/customization/extending-panels.mdx +170 -113
- package/content/resources/tutorials/add-a-widget.mdx +110 -0
- package/content/resources/tutorials/add-order-detail-button.mdx +113 -0
- package/content/resources/tutorials/custom-panel-page.mdx +2 -2
- package/content/resources/tutorials/customize-navigation.mdx +110 -0
- package/content/resources/tutorials/extend-forms-and-tables.mdx +188 -0
- package/content/resources/tutorials/extend-onboarding.mdx +253 -0
- package/content/resources/tutorials/import-export-products.mdx +3 -3
- package/content/resources/tutorials/store-setup-checklist.mdx +214 -0
- package/llms.txt +17 -3
- package/package.json +1 -1
- package/content/resources/tutorials/recompose-a-page.mdx +0 -129
- package/content/resources/tutorials/replace-panel-components.mdx +0 -116
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Customize navigation"
|
|
3
|
+
description: "Reorder, hide, relabel, and re-parent built-in sidebar items with a single _navigation.ts file and defineNavigationConfig."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
The built-in sidebar ships a fixed set of items — Orders, Products, Customers, and so on. To reshape them without replacing the whole sidebar, author one host-owned file: `src/_navigation.ts`. It reorders, hides, relabels, and re-parents **built-in** items, and it is the single source of truth for the sidebar's shape.
|
|
7
|
+
|
|
8
|
+
<Info>
|
|
9
|
+
**When to use this vs. a `config` export.** New pages you add via [drop-in routes](/rc/resources/tutorials/custom-panel-page) place their own sidebar item through `defineRouteConfig({ label, rank, nested })`. `_navigation.ts` is for the items you *didn't* create — the built-in ones. The two layer cleanly: custom routes place themselves; `_navigation.ts` reshapes the built-ins.
|
|
10
|
+
</Info>
|
|
11
|
+
|
|
12
|
+
## What you'll build
|
|
13
|
+
|
|
14
|
+
A vendor sidebar with Orders pinned to the top, Price Lists hidden, and Campaigns moved under Orders.
|
|
15
|
+
|
|
16
|
+
## Register the typed targets (once)
|
|
17
|
+
|
|
18
|
+
Nav item ids are typed and generated per panel. Register them once with a single ambient reference in your app's `src` (already shipped by `create-mercur-app`):
|
|
19
|
+
|
|
20
|
+
```typescript apps/vendor/src/extension-targets.d.ts
|
|
21
|
+
/// <reference types="@mercurjs/vendor/extension-targets" />
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
With it present, `id` and `nested` autocomplete and an unknown id fails `tsc`.
|
|
25
|
+
|
|
26
|
+
## Author the navigation file
|
|
27
|
+
|
|
28
|
+
<Steps>
|
|
29
|
+
<Step title="Create src/_navigation.ts">
|
|
30
|
+
The file is host-owned and underscore-prefixed. Default-export a `defineNavigationConfig` with an `items` array of overrides:
|
|
31
|
+
|
|
32
|
+
```ts apps/vendor/src/_navigation.ts
|
|
33
|
+
import { defineNavigationConfig } from "@mercurjs/dashboard-sdk"
|
|
34
|
+
|
|
35
|
+
export default defineNavigationConfig({
|
|
36
|
+
items: [
|
|
37
|
+
{ id: "orders", rank: 0 }, // pin to the top
|
|
38
|
+
{ id: "price-lists", hidden: true }, // hide from the sidebar
|
|
39
|
+
{ id: "campaigns", nested: "orders" }, // re-parent under Orders
|
|
40
|
+
],
|
|
41
|
+
})
|
|
42
|
+
```
|
|
43
|
+
</Step>
|
|
44
|
+
<Step title="Know the override fields">
|
|
45
|
+
Each entry targets one built-in item by its stable `id`:
|
|
46
|
+
|
|
47
|
+
| Field | Type | Effect |
|
|
48
|
+
|-------|------|--------|
|
|
49
|
+
| `id` | `NavItemId` | **Required.** The built-in item to override (top-level or nested) |
|
|
50
|
+
| `rank` | `number` | Order within its parent (lower first) |
|
|
51
|
+
| `hidden` | `boolean` | Remove it from the sidebar |
|
|
52
|
+
| `label` | `string` | Relabel (i18n key or literal) |
|
|
53
|
+
| `icon` | `ComponentType` | Replace its icon |
|
|
54
|
+
| `nested` | `NavParentId \| null` | Re-parent under another top-level item; `null` promotes a nested item to top level |
|
|
55
|
+
|
|
56
|
+
Both `id` and `nested` are checked against the panel's generated `NavItemRegistry` / `NavParentRegistry`.
|
|
57
|
+
</Step>
|
|
58
|
+
<Step title="Reload the panel">
|
|
59
|
+
Open the vendor portal — Orders sits at the top, Price Lists is gone from the menu, and Campaigns now appears under Orders. The route for a hidden item stays reachable directly by URL unless you also remove it.
|
|
60
|
+
</Step>
|
|
61
|
+
</Steps>
|
|
62
|
+
|
|
63
|
+
## Common recipes
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
export default defineNavigationConfig({
|
|
67
|
+
items: [
|
|
68
|
+
{ id: "payouts", label: "Settlements" }, // relabel
|
|
69
|
+
{ id: "categories", nested: null, rank: 1 }, // promote a nested item to top level
|
|
70
|
+
{ id: "collections", nested: "orders" }, // move a nested item under a different parent
|
|
71
|
+
{ id: "inventory", hidden: true }, // hide a built-in
|
|
72
|
+
],
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Verify
|
|
77
|
+
|
|
78
|
+
1. The top-level order reflects your `rank` values, with `orders` first.
|
|
79
|
+
2. `price-lists` no longer appears in the sidebar.
|
|
80
|
+
3. `campaigns` renders as a child under Orders.
|
|
81
|
+
4. Set `id: "not-an-item"` — `bun run lint` (tsc) fails against `NavItemRegistry`.
|
|
82
|
+
5. Delete `_navigation.ts` — the default sidebar returns.
|
|
83
|
+
|
|
84
|
+
## FAQ
|
|
85
|
+
|
|
86
|
+
<AccordionGroup>
|
|
87
|
+
<Accordion title="Can an installed block reorder the sidebar?">
|
|
88
|
+
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`.
|
|
89
|
+
</Accordion>
|
|
90
|
+
<Accordion title="What ids can I target?">
|
|
91
|
+
Any built-in item, top-level or nested, by its own id — e.g. `orders`, `products`, `categories`, `collections`, `campaigns`, `customer-groups`. Let your editor autocomplete `id:` against `NavItemId`; the full set is generated into your panel's `extension-targets.d.ts`.
|
|
92
|
+
</Accordion>
|
|
93
|
+
<Accordion title="Does this work in the admin panel too?">
|
|
94
|
+
Yes — drop `src/_navigation.ts` in the admin app and reference `@mercurjs/admin/extension-targets`. Each panel ships its own nav id set.
|
|
95
|
+
</Accordion>
|
|
96
|
+
<Accordion title="How do I add a brand-new sidebar item?">
|
|
97
|
+
That's a [drop-in route](/rc/resources/tutorials/custom-panel-page) with a `config` export — `_navigation.ts` only reshapes built-in items, it doesn't create routes.
|
|
98
|
+
</Accordion>
|
|
99
|
+
</AccordionGroup>
|
|
100
|
+
|
|
101
|
+
## Next steps
|
|
102
|
+
|
|
103
|
+
<CardGroup cols={2}>
|
|
104
|
+
<Card title="Add a custom panel page" href="/rc/resources/tutorials/custom-panel-page">
|
|
105
|
+
Add a new screen with its own sidebar item.
|
|
106
|
+
</Card>
|
|
107
|
+
<Card title="Add a widget" href="/rc/resources/tutorials/add-a-widget">
|
|
108
|
+
Inject a component into a built-in page.
|
|
109
|
+
</Card>
|
|
110
|
+
</CardGroup>
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Extend forms and tables"
|
|
3
|
+
description: "Add validated fields to built-in forms, replace fields in detail sections, and add list columns with defineCustomFieldsConfig and createFormHelper."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
`defineCustomFieldsConfig` is Mercur's model-scoped extension surface: from one file per model, you add validated fields to built-in create/edit forms, replace/remove/add fields in detail sections, and add columns to the list table — all wired into the built-in page, all typed against the model's generated registry.
|
|
7
|
+
|
|
8
|
+
<Info>
|
|
9
|
+
**UI, not schema.** This helper is a *panel* surface — it renders, validates, and displays fields. It does not create database columns. To *store* extra data, use the backend [Custom Fields module](/rc/resources/customization/custom-fields) or your own API route/workflow. In the MVP, panel custom fields for `product` are submitted under `additional_data` and persisted onto the product's `metadata`.
|
|
10
|
+
</Info>
|
|
11
|
+
|
|
12
|
+
## What you'll build
|
|
13
|
+
|
|
14
|
+
An `ERP ID` field on the vendor product edit form, shown in the product's detail section and as a list-table column — from a single `src/custom-fields/product.tsx`.
|
|
15
|
+
|
|
16
|
+
## Register the typed targets (once)
|
|
17
|
+
|
|
18
|
+
Models, form zones, display zones, and built-in field ids are typed per panel. Register them once (shipped by `create-mercur-app`):
|
|
19
|
+
|
|
20
|
+
```typescript apps/vendor/src/extension-targets.d.ts
|
|
21
|
+
/// <reference types="@mercurjs/vendor/extension-targets" />
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Build the config
|
|
25
|
+
|
|
26
|
+
<Steps>
|
|
27
|
+
<Step title="Create the model file">
|
|
28
|
+
Drop `src/custom-fields/<model>.tsx` and default-export a `defineCustomFieldsConfig`. `createFormHelper` (from `@mercurjs/dashboard-shared`) turns a Zod schema into an input type + validation:
|
|
29
|
+
|
|
30
|
+
```tsx apps/vendor/src/custom-fields/product.tsx
|
|
31
|
+
import { defineCustomFieldsConfig } from "@mercurjs/dashboard-sdk"
|
|
32
|
+
import { createFormHelper } from "@mercurjs/dashboard-shared"
|
|
33
|
+
|
|
34
|
+
type ProductWithMeta = { metadata?: Record<string, unknown> }
|
|
35
|
+
const form = createFormHelper<ProductWithMeta>()
|
|
36
|
+
|
|
37
|
+
export default defineCustomFieldsConfig({
|
|
38
|
+
model: "product",
|
|
39
|
+
forms: [
|
|
40
|
+
{
|
|
41
|
+
zone: "edit",
|
|
42
|
+
fields: {
|
|
43
|
+
erp_id: form.define({
|
|
44
|
+
validation: form.string().optional(),
|
|
45
|
+
label: "ERP ID",
|
|
46
|
+
description: "External system identifier",
|
|
47
|
+
placeholder: "ERP-000",
|
|
48
|
+
defaultValue: (data) => (data?.metadata?.erp_id as string) ?? "",
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
</Step>
|
|
56
|
+
<Step title="Add a detail-section display">
|
|
57
|
+
`displays[]` targets a detail-page section by its `zone` id. Keyed by field `id`, an entry **adds**, **replaces**, or **removes** a field:
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
import { Text } from "@medusajs/ui"
|
|
61
|
+
|
|
62
|
+
// ...inside defineCustomFieldsConfig:
|
|
63
|
+
displays: [
|
|
64
|
+
{
|
|
65
|
+
zone: "general",
|
|
66
|
+
fields: [
|
|
67
|
+
// ADD — an unknown id appends a new read-only row
|
|
68
|
+
{
|
|
69
|
+
id: "erp_id",
|
|
70
|
+
component: ({ data }) => (
|
|
71
|
+
<Text size="small" className="text-ui-fg-subtle px-6 py-4">
|
|
72
|
+
ERP ID: {String(data?.metadata?.erp_id ?? "-")}
|
|
73
|
+
</Text>
|
|
74
|
+
),
|
|
75
|
+
},
|
|
76
|
+
// REMOVE — a built-in id + null hides the field
|
|
77
|
+
{ id: "subtitle", component: null },
|
|
78
|
+
// REPLACE — a built-in id + component overrides its render
|
|
79
|
+
{
|
|
80
|
+
id: "handle",
|
|
81
|
+
component: ({ data }) => (
|
|
82
|
+
<Text size="small" className="text-ui-fg-subtle px-6 py-4">
|
|
83
|
+
/{(data as { handle?: string })?.handle}
|
|
84
|
+
</Text>
|
|
85
|
+
),
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Built-in field ids (like `subtitle`, `handle`, `status`, `title`) autocomplete from the panel's generated `CustomFieldsRegistry`; an unknown id is treated as an added row.
|
|
93
|
+
</Step>
|
|
94
|
+
<Step title="Add a list column">
|
|
95
|
+
The `list` block extends the model's list table — add or override columns by id, hide built-in columns, and reorder:
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
// ...inside defineCustomFieldsConfig:
|
|
99
|
+
list: {
|
|
100
|
+
columns: [
|
|
101
|
+
{
|
|
102
|
+
id: "erp_id",
|
|
103
|
+
header: "ERP",
|
|
104
|
+
component: ({ row }) => String(row.metadata?.erp_id ?? "-"),
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
viewDefaults: {
|
|
108
|
+
columnVisibility: { collection: false }, // hide a built-in column
|
|
109
|
+
columnOrder: ["product", "erp_id", "status"], // reorder
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
```
|
|
113
|
+
</Step>
|
|
114
|
+
<Step title="Reload the panel">
|
|
115
|
+
Open the vendor portal. The product **edit** drawer shows the ERP ID field (validated on submit and persisted via `additional_data`), the detail **general** section shows the ERP ID row (with `subtitle` removed and `handle` re-rendered), and the product **list** shows the ERP column.
|
|
116
|
+
</Step>
|
|
117
|
+
</Steps>
|
|
118
|
+
|
|
119
|
+
## The `createFormHelper` surface
|
|
120
|
+
|
|
121
|
+
`createFormHelper<T>()` exposes a Zod-based surface that drives both the input type and its validation:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
const form = createFormHelper<T>()
|
|
125
|
+
|
|
126
|
+
form.define({
|
|
127
|
+
validation: form.string().min(1), // string | number | boolean | date | array | object | null | nullable | coerce
|
|
128
|
+
label: "…",
|
|
129
|
+
description: "…",
|
|
130
|
+
placeholder: "…",
|
|
131
|
+
defaultValue: "" | ((data) => /* derive from the entity */),
|
|
132
|
+
component, // optional custom render component
|
|
133
|
+
})
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Fields render through the standard `Form.Field → Form.Item` chain (never a raw `Controller`) and participate in the existing `TabbedForm` / `RouteDrawer` submit and validation flow.
|
|
137
|
+
|
|
138
|
+
## Linked-module data
|
|
139
|
+
|
|
140
|
+
To read data from a linked module alongside the entity, declare it with `link`; those relations are fetched with the entity and become available to columns and displays:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
export default defineCustomFieldsConfig({
|
|
144
|
+
model: "product",
|
|
145
|
+
link: "brand", // or ["brand", "warranty"]
|
|
146
|
+
list: {
|
|
147
|
+
columns: [{ id: "brand_name", header: "Brand", component: ({ row }) => row.brand?.name }],
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The SDK derives the fetch query from `link` and merges it into the built-in query with the `+`/`-` convention — you never hand-write the field list.
|
|
153
|
+
|
|
154
|
+
## Verify
|
|
155
|
+
|
|
156
|
+
1. The ERP ID field renders in the product edit drawer and validates on submit.
|
|
157
|
+
2. Saving persists the value (visible on reload) via `additional_data` → `metadata`.
|
|
158
|
+
3. The detail general section shows the ERP row, hides `subtitle`, and re-renders `handle`.
|
|
159
|
+
4. The product list shows the ERP column, hides `collection`, and reorders columns.
|
|
160
|
+
5. Set `zone: "nope"` in `forms` — `bun run lint` (tsc) fails against the model's registry.
|
|
161
|
+
|
|
162
|
+
## FAQ
|
|
163
|
+
|
|
164
|
+
<AccordionGroup>
|
|
165
|
+
<Accordion title="Which models and zones are available?">
|
|
166
|
+
Today: the `product` model in the vendor portal, with form zone `edit` and display zone `general`. The valid set per panel is generated into `CustomFieldsRegistry` in `extension-targets.d.ts` — autocomplete `model` / `zone` to see what's mounted.
|
|
167
|
+
</Accordion>
|
|
168
|
+
<Accordion title="Where is the value actually stored?">
|
|
169
|
+
In the MVP, product custom fields are submitted under `additional_data` and persisted onto `product.metadata`. `defineCustomFieldsConfig` itself doesn't create a column — for durable, queryable storage model it with the backend [Custom Fields module](/rc/resources/customization/custom-fields) or a custom route/workflow.
|
|
170
|
+
</Accordion>
|
|
171
|
+
<Accordion title="Can I extend the onboarding wizard?">
|
|
172
|
+
That's the same helper with `zone: "onboarding"` and `tab` set to a wizard step id (vendor only). It's designed but not mounted in the current MVP — the runtime host exists; the wizard mount is a follow-up.
|
|
173
|
+
</Accordion>
|
|
174
|
+
<Accordion title="Can a block ship custom fields?">
|
|
175
|
+
Yes — a [block](/rc/learn/blocks) can include `src/custom-fields/` files in its `vendor_ui` / `admin_ui` entry, aggregated like the host app's.
|
|
176
|
+
</Accordion>
|
|
177
|
+
</AccordionGroup>
|
|
178
|
+
|
|
179
|
+
## Next steps
|
|
180
|
+
|
|
181
|
+
<CardGroup cols={2}>
|
|
182
|
+
<Card title="Custom Fields module" href="/rc/resources/customization/custom-fields">
|
|
183
|
+
Persist extra data on an entity with a generated side table.
|
|
184
|
+
</Card>
|
|
185
|
+
<Card title="Add a widget" href="/rc/resources/tutorials/add-a-widget">
|
|
186
|
+
Inject a component at a built-in zone.
|
|
187
|
+
</Card>
|
|
188
|
+
</CardGroup>
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Extend the onboarding flow"
|
|
3
|
+
description: "Add a field to the vendor store-setup surface, submit it through additional_data, and persist it durably from a workflow hook — end to end, without forking."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
The vendor **store-setup / onboarding** surface is a widget zone (`seller.setup`) that renders the full `seller` object as its `data`. That makes onboarding a full extension seam: drop a widget to add UI, carry the new value to the API on the built-in seller routes through `additional_data`, and persist it from a workflow hook — the same three layers you'd wire in plain Medusa, kept intact by Mercur.
|
|
7
|
+
|
|
8
|
+
<Info>
|
|
9
|
+
**Three layers, one flow.** The panel (a `seller.setup` widget) *renders and collects*. The vendor seller route *carries* the value through `additional_data` — no core schema change. A `sellersUpdated` workflow hook *persists* it. Each layer is additive: nothing built-in is replaced.
|
|
10
|
+
</Info>
|
|
11
|
+
|
|
12
|
+
## What you'll build
|
|
13
|
+
|
|
14
|
+
A "Tax ID" prompt on the vendor store-setup surface. The vendor types a VAT number; it rides `additional_data` to `POST /vendor/sellers/:id`, and a workflow hook stores it durably through the [Custom Fields module](/rc/resources/customization/custom-fields).
|
|
15
|
+
|
|
16
|
+
## Register the typed targets (once)
|
|
17
|
+
|
|
18
|
+
Widget zones are typed ids the vendor panel generates from its own pages and ships as `@mercurjs/vendor/extension-targets`. Register them once (shipped by `create-mercur-app`):
|
|
19
|
+
|
|
20
|
+
```typescript apps/vendor/src/extension-targets.d.ts
|
|
21
|
+
/// <reference types="@mercurjs/vendor/extension-targets" />
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
With it present, `seller.setup` autocompletes and an invalid zone fails `tsc` instead of silently doing nothing.
|
|
25
|
+
|
|
26
|
+
## 1 — Render on the onboarding surface
|
|
27
|
+
|
|
28
|
+
<Steps>
|
|
29
|
+
<Step title="Add the store-setup widget">
|
|
30
|
+
Drop a file under `src/widgets/`. Export the component as the **default** and a `config` with `zone: "seller.setup.before"`. The zone hands your component the `seller` as `data`:
|
|
31
|
+
|
|
32
|
+
```tsx apps/vendor/src/widgets/tax-id-setup.tsx
|
|
33
|
+
import "@mercurjs/vendor/extension-targets"
|
|
34
|
+
import { defineWidgetConfig } from "@mercurjs/dashboard-sdk"
|
|
35
|
+
import { SellerDTO } from "@mercurjs/types"
|
|
36
|
+
import { Container, Heading, Input, Button, Text, toast } from "@medusajs/ui"
|
|
37
|
+
import { useMutation } from "@tanstack/react-query"
|
|
38
|
+
import { useState } from "react"
|
|
39
|
+
|
|
40
|
+
import { client } from "../lib/client"
|
|
41
|
+
|
|
42
|
+
export const config = defineWidgetConfig({
|
|
43
|
+
zone: "seller.setup.before",
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const TaxIdSetup = ({ data: seller }: { data?: SellerDTO }) => {
|
|
47
|
+
const [taxId, setTaxId] = useState("")
|
|
48
|
+
|
|
49
|
+
const { mutate, isPending } = useMutation({
|
|
50
|
+
mutationFn: () =>
|
|
51
|
+
client.vendor.sellers.$id.mutate({
|
|
52
|
+
$id: seller!.id,
|
|
53
|
+
// additional_data is accepted on every vendor seller route —
|
|
54
|
+
// it never touches the built-in seller columns.
|
|
55
|
+
additional_data: { tax_id: taxId },
|
|
56
|
+
}),
|
|
57
|
+
onSuccess: () => toast.success("Tax ID saved"),
|
|
58
|
+
onError: () => toast.error("Could not save Tax ID"),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
if (!seller) {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Container className="mb-2 flex items-end gap-x-3">
|
|
67
|
+
<div className="flex-1">
|
|
68
|
+
<Heading level="h2">Complete your tax details</Heading>
|
|
69
|
+
<Text size="small" className="text-ui-fg-subtle">
|
|
70
|
+
Add your VAT / Tax ID to finish store setup.
|
|
71
|
+
</Text>
|
|
72
|
+
<Input
|
|
73
|
+
className="mt-3"
|
|
74
|
+
placeholder="VAT-000000"
|
|
75
|
+
value={taxId}
|
|
76
|
+
onChange={(e) => setTaxId(e.target.value)}
|
|
77
|
+
data-testid="tax-id-setup-input"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
<Button
|
|
81
|
+
size="small"
|
|
82
|
+
isLoading={isPending}
|
|
83
|
+
disabled={!taxId}
|
|
84
|
+
onClick={() => mutate()}
|
|
85
|
+
data-testid="tax-id-setup-save"
|
|
86
|
+
>
|
|
87
|
+
Save
|
|
88
|
+
</Button>
|
|
89
|
+
</Container>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default TaxIdSetup
|
|
94
|
+
```
|
|
95
|
+
</Step>
|
|
96
|
+
<Step title="Understand where it renders">
|
|
97
|
+
`seller.setup` is hosted in two places, both passing the same `seller` as `data`:
|
|
98
|
+
|
|
99
|
+
| Host | When it shows |
|
|
100
|
+
|------|---------------|
|
|
101
|
+
| The vendor shell (above the page outlet) | On top-level routes — the dashboard "home" onboarding banner |
|
|
102
|
+
| The store settings detail page | Always, above the store status banner |
|
|
103
|
+
|
|
104
|
+
A single widget file covers both. Multiple `seller.setup.before` / `.after` widgets stack in registration order.
|
|
105
|
+
</Step>
|
|
106
|
+
</Steps>
|
|
107
|
+
|
|
108
|
+
<Note>
|
|
109
|
+
**`client` is your app's typed SDK.** `create-mercur-app` ships `apps/vendor/src/lib/client.ts` — a `createClient<Routes>()` instance. `client.vendor.sellers.$id.mutate(...)` is the typed `POST /vendor/sellers/:id`, so the request and response types match the backend route.
|
|
110
|
+
</Note>
|
|
111
|
+
|
|
112
|
+
## 2 — Carry the value through `additional_data`
|
|
113
|
+
|
|
114
|
+
You don't touch the seller route or its validator. Every vendor and admin seller route already wraps its body with `WithAdditionalData`, so an unknown `additional_data` object is accepted and forwarded into the workflow untouched:
|
|
115
|
+
|
|
116
|
+
```ts packages/core/src/api/vendor/sellers/[id]/route.ts (built-in — for reference)
|
|
117
|
+
const { additional_data, ...update } = req.validatedBody
|
|
118
|
+
|
|
119
|
+
await updateSellersWorkflow(req.scope).run({
|
|
120
|
+
input: {
|
|
121
|
+
selector: { id: req.params.id },
|
|
122
|
+
update,
|
|
123
|
+
additional_data, // ← forwarded to the workflow's hooks
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
That is the whole "wiring" step: your `{ tax_id }` payload arrives in the workflow as `additional_data` without a schema change.
|
|
129
|
+
|
|
130
|
+
## 3 — Persist it from a workflow hook
|
|
131
|
+
|
|
132
|
+
`updateSellersWorkflow` exposes a `sellersUpdated` hook that runs after the update with `{ sellers, additional_data }`. Subscribe to it in your Medusa app and persist the value.
|
|
133
|
+
|
|
134
|
+
<Steps>
|
|
135
|
+
<Step title="Declare a durable field">
|
|
136
|
+
Register a `Seller` custom field so the value gets a real, queryable column (no migration to hand-write):
|
|
137
|
+
|
|
138
|
+
```ts apps/api/medusa-config.ts
|
|
139
|
+
module.exports = defineConfig({
|
|
140
|
+
// ...
|
|
141
|
+
modules: [
|
|
142
|
+
{
|
|
143
|
+
resolve: "@mercurjs/core/modules/custom-fields",
|
|
144
|
+
options: {
|
|
145
|
+
customFields: {
|
|
146
|
+
Seller: {
|
|
147
|
+
tax_id: { type: "string", nullable: true },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
})
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
bunx medusa db:migrate
|
|
158
|
+
```
|
|
159
|
+
</Step>
|
|
160
|
+
<Step title="Subscribe to the hook">
|
|
161
|
+
Drop a file under `src/workflows/` in your Medusa app. Medusa imports everything under `src/workflows` at boot, so registering the hook is just defining it. Read `additional_data`, upsert through the Custom Fields service:
|
|
162
|
+
|
|
163
|
+
```ts apps/api/src/workflows/hooks/seller-tax-id.ts
|
|
164
|
+
import { updateSellersWorkflow } from "@mercurjs/core/workflows"
|
|
165
|
+
import { MercurModules } from "@mercurjs/types"
|
|
166
|
+
|
|
167
|
+
updateSellersWorkflow.hooks.sellersUpdated(
|
|
168
|
+
async ({ sellers, additional_data }, { container }) => {
|
|
169
|
+
const taxId = additional_data?.tax_id
|
|
170
|
+
if (typeof taxId !== "string") {
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const customFields = container.resolve(MercurModules.CUSTOM_FIELDS)
|
|
175
|
+
|
|
176
|
+
await customFields.upsert(
|
|
177
|
+
"seller",
|
|
178
|
+
sellers.map((seller) => ({ id: seller.id, tax_id: taxId })),
|
|
179
|
+
)
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
<Tip>
|
|
185
|
+
The hook fires for **every** seller update, not only your widget's — always guard on the field being present (`typeof taxId !== "string"`) so unrelated edits (name, address, status) pass through untouched.
|
|
186
|
+
</Tip>
|
|
187
|
+
</Step>
|
|
188
|
+
<Step title="Read it back">
|
|
189
|
+
The value is now linked to the seller and queryable through Medusa's remote query:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
const { data: [seller] } = await query.graph({
|
|
193
|
+
entity: "seller",
|
|
194
|
+
fields: ["id", "name", "custom_fields.tax_id"],
|
|
195
|
+
filters: { id: sellerId },
|
|
196
|
+
})
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Add `custom_fields.*` to the `/vendor/sellers/me` query config if you want the widget to reflect the saved value on reload.
|
|
200
|
+
</Step>
|
|
201
|
+
</Steps>
|
|
202
|
+
|
|
203
|
+
## How the layers connect
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
seller.setup widget → client.vendor.sellers.$id.mutate({ additional_data })
|
|
207
|
+
│
|
|
208
|
+
▼
|
|
209
|
+
POST /vendor/sellers/:id → updateSellersWorkflow({ update, additional_data })
|
|
210
|
+
│
|
|
211
|
+
▼
|
|
212
|
+
hook: sellersUpdated({ sellers, additional_data }) → customFields.upsert("seller", …)
|
|
213
|
+
│
|
|
214
|
+
▼
|
|
215
|
+
seller.custom_fields.tax_id (durable, queryable)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Verify
|
|
219
|
+
|
|
220
|
+
1. Open the vendor portal — the Tax ID prompt renders on the dashboard home and on **Settings → Store**.
|
|
221
|
+
2. Enter a value and save — the mutation succeeds (`toast.success`) and hits `POST /vendor/sellers/:id`.
|
|
222
|
+
3. `query.graph({ entity: "seller", fields: ["custom_fields.tax_id"] })` returns the saved value.
|
|
223
|
+
4. Edit an unrelated field (store name) — the seller update still works and the guard skips the upsert.
|
|
224
|
+
5. Set `zone: "not.a.zone"` on the widget — `bun run lint` (tsc) fails against `WidgetZoneId`.
|
|
225
|
+
6. Delete the widget file — the prompt disappears; the seller route and hook are unaffected.
|
|
226
|
+
|
|
227
|
+
## FAQ
|
|
228
|
+
|
|
229
|
+
<AccordionGroup>
|
|
230
|
+
<Accordion title="Why additional_data instead of adding a body field?">
|
|
231
|
+
The seller routes' validators are core-owned. `additional_data` is the sanctioned escape hatch — every vendor and admin route wraps its body with `WithAdditionalData`, so you carry extra context to the workflow hooks without patching the request schema or forking the route.
|
|
232
|
+
</Accordion>
|
|
233
|
+
<Accordion title="Which seller workflows expose hooks?">
|
|
234
|
+
`updateSellersWorkflow` exposes `sellersUpdated`, and `createSellerAccountWorkflow` (the `POST /vendor/sellers` onboarding submit) exposes `sellerAccountCreated` — both carry `{ additional_data }`. Use `sellerAccountCreated` to capture data at first registration and `sellersUpdated` for later edits. See the [workflow references](/rc/references/workflows/seller/update-sellers).
|
|
235
|
+
</Accordion>
|
|
236
|
+
<Accordion title="Can I store it on the seller's metadata instead?">
|
|
237
|
+
Yes — for a quick, non-queryable value, resolve the seller module in the hook and write to `seller.metadata`. Reach for the [Custom Fields module](/rc/resources/customization/custom-fields) when you want a typed, queryable column, which is what most onboarding data (tax IDs, compliance flags) needs.
|
|
238
|
+
</Accordion>
|
|
239
|
+
<Accordion title="Does the hook run inside the request?">
|
|
240
|
+
Yes — workflow hooks run as steps of the workflow the route invokes, with the same compensation/rollback semantics. If your hook throws, the seller update rolls back. Keep slow or best-effort work (external syncs) in a subscriber on the emitted `seller.updated` event instead.
|
|
241
|
+
</Accordion>
|
|
242
|
+
</AccordionGroup>
|
|
243
|
+
|
|
244
|
+
## Next steps
|
|
245
|
+
|
|
246
|
+
<CardGroup cols={2}>
|
|
247
|
+
<Card title="Extend a workflow" href="/rc/resources/customization/extend-a-workflow">
|
|
248
|
+
The full hook + compensation model for Mercur workflows.
|
|
249
|
+
</Card>
|
|
250
|
+
<Card title="Custom Fields module" href="/rc/resources/customization/custom-fields">
|
|
251
|
+
Durable, queryable storage for the data your hook writes.
|
|
252
|
+
</Card>
|
|
253
|
+
</CardGroup>
|
|
@@ -6,7 +6,7 @@ description: "Install the product-import-export block and give vendors CSV impor
|
|
|
6
6
|
Bulk catalog operations belong to vendors, not the operator — a seller migrating from another platform needs to bring hundreds of listings with them. The `product-import-export` block adds CSV import and export to the Vendor Portal: API routes, workflows built on Medusa's product CSV steps, and drawer UIs wired into the product list.
|
|
7
7
|
|
|
8
8
|
<Info>
|
|
9
|
-
**This block is also a masterclass in Mercur's extension model.** It ships backend workflows and routes into your API package, and it
|
|
9
|
+
**This block is also a masterclass in Mercur's extension model.** It ships backend workflows and routes into your API package, and it adds Import/Export actions to the vendor products area through the panel extension surface. After installing, read its source; you own every file.
|
|
10
10
|
</Info>
|
|
11
11
|
|
|
12
12
|
## What you'll build
|
|
@@ -87,8 +87,8 @@ A vendor product list with **Import** and **Export** header buttons, a working C
|
|
|
87
87
|
## Next steps
|
|
88
88
|
|
|
89
89
|
<CardGroup cols={2}>
|
|
90
|
-
<Card title="
|
|
91
|
-
|
|
90
|
+
<Card title="Add a widget" href="/rc/resources/tutorials/add-a-widget">
|
|
91
|
+
Inject a component at a built-in zone with `defineWidgetConfig`.
|
|
92
92
|
</Card>
|
|
93
93
|
<Card title="Build your own block" href="/rc/resources/tutorials/build-a-block">
|
|
94
94
|
Package a feature like this one and publish it to your own registry.
|