@mercurjs/docs 2.2.0-canary.41 → 2.2.0-canary.42

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.
@@ -33,6 +33,8 @@ await confirmProductsWorkflow(container).run({
33
33
  Operator-only note stored on the audit change and included in the emitted event.
34
34
  </ParamField>
35
35
 
36
+ <ParamField body="additional_data" type="object">Custom data passed through to the workflow hooks.</ParamField>
37
+
36
38
  ## Result
37
39
 
38
40
  <ResponseField name="result" type="void">
@@ -33,6 +33,8 @@ await rejectProductWorkflow(container).run({
33
33
  Actor id recorded on the audit trail entry.
34
34
  </ParamField>
35
35
 
36
+ <ParamField body="additional_data" type="object">Custom data passed through to the workflow hooks.</ParamField>
37
+
36
38
  ## Result
37
39
 
38
40
  <ResponseField name="result" type="void">
@@ -33,6 +33,8 @@ await requestProductChangeWorkflow(container).run({
33
33
  Actor id recorded on the audit trail entry.
34
34
  </ParamField>
35
35
 
36
+ <ParamField body="additional_data" type="object">Custom data passed through to the workflow hooks.</ParamField>
37
+
36
38
  ## Result
37
39
 
38
40
  <ResponseField name="result" type="void">
@@ -19,6 +19,8 @@ await approveSellerWorkflow(container).run({
19
19
 
20
20
  <ParamField body="seller_id" type="string" required>Id of the seller to approve.</ParamField>
21
21
 
22
+ <ParamField body="additional_data" type="object">Custom data passed through to the workflow hooks.</ParamField>
23
+
22
24
  ## Result
23
25
 
24
26
  <ResponseField name="result" type="void">Nothing is returned.</ResponseField>
@@ -20,6 +20,8 @@ await suspendSellerWorkflow(container).run({
20
20
  <ParamField body="seller_id" type="string" required>Id of the seller to suspend.</ParamField>
21
21
  <ParamField body="reason" type="string">Reason stored in `status_reason`.</ParamField>
22
22
 
23
+ <ParamField body="additional_data" type="object">Custom data passed through to the workflow hooks.</ParamField>
24
+
23
25
  ## Result
24
26
 
25
27
  <ResponseField name="result" type="void">Nothing is returned.</ResponseField>
@@ -20,6 +20,8 @@ await terminateSellerWorkflow(container).run({
20
20
  <ParamField body="seller_id" type="string" required>Id of the seller to terminate.</ParamField>
21
21
  <ParamField body="reason" type="string">Reason stored in `status_reason`.</ParamField>
22
22
 
23
+ <ParamField body="additional_data" type="object">Custom data passed through to the workflow hooks.</ParamField>
24
+
23
25
  ## Result
24
26
 
25
27
  <ResponseField name="result" type="void">Nothing is returned.</ResponseField>
@@ -19,6 +19,8 @@ await unsuspendSellerWorkflow(container).run({
19
19
 
20
20
  <ParamField body="seller_id" type="string" required>Id of the suspended seller.</ParamField>
21
21
 
22
+ <ParamField body="additional_data" type="object">Custom data passed through to the workflow hooks.</ParamField>
23
+
22
24
  ## Result
23
25
 
24
26
  <ResponseField name="result" type="void">Nothing is returned.</ResponseField>
@@ -19,6 +19,8 @@ await unterminateSellerWorkflow(container).run({
19
19
 
20
20
  <ParamField body="seller_id" type="string" required>Id of the terminated seller.</ParamField>
21
21
 
22
+ <ParamField body="additional_data" type="object">Custom data passed through to the workflow hooks.</ParamField>
23
+
22
24
  ## Result
23
25
 
24
26
  <ResponseField name="result" type="void">Nothing is returned.</ResponseField>
@@ -70,6 +70,7 @@ Zones mounted today in the **vendor portal**:
70
70
  | Zone | Where it renders |
71
71
  |------|------------------|
72
72
  | `product.list.before` / `.after` | Vendor product list page |
73
+ | `store.setup.before` / `.after` | The store-setup / onboarding surface (dashboard home + store settings), passed the `seller` as `data` |
73
74
  | `login.logo.*` | The logo slot on the public login screen |
74
75
  | `login.before.*` / `login.after.*` | Around the login form (rendered before authentication) |
75
76
 
@@ -103,8 +104,8 @@ The full, valid set is typed as `WidgetZoneId` and generated into `@mercurjs/ven
103
104
  <Card title="Extend forms and tables" href="/rc/resources/tutorials/extend-forms-and-tables">
104
105
  Add validated fields and columns with defineCustomFieldsConfig.
105
106
  </Card>
106
- <Card title="Customize navigation" href="/rc/resources/tutorials/customize-navigation">
107
- Reorder, hide, and re-parent sidebar items.
107
+ <Card title="Extend the onboarding flow" href="/rc/resources/tutorials/extend-onboarding">
108
+ Add a store-setup field and persist it through a workflow hook.
108
109
  </Card>
109
110
  </CardGroup>
110
111
  </content>
@@ -0,0 +1,254 @@
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 (`store.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 `store.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, `store.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: "store.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: "store.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
+ `store.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 `store.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
+ store.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>
254
+ </content>
package/llms.txt CHANGED
@@ -44,6 +44,7 @@ package (`node_modules/@mercurjs/docs/`).
44
44
  - [Add a custom panel page](content/resources/tutorials/custom-panel-page.mdx) — Add a page to the vendor portal with file-based routing — the dashboard SDK wires it in automatically.
45
45
  - [Customize navigation](content/resources/tutorials/customize-navigation.mdx) — Reorder, hide, relabel, and re-parent built-in sidebar items with a single _navigation.ts file and defineNavigationConfig.
46
46
  - [Extend forms and tables](content/resources/tutorials/extend-forms-and-tables.mdx) — Add validated fields to built-in forms, replace fields in detail sections, and add list columns with defineCustomFieldsConfig and createFormHelper.
47
+ - [Extend the onboarding flow](content/resources/tutorials/extend-onboarding.mdx) — 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.
47
48
  - [Build your first marketplace](content/resources/tutorials/first-marketplace.mdx) — Go end-to-end: create a project, approve a seller, list a product, place a split order, and watch a payout settle.
48
49
  - [Handle product requests](content/resources/tutorials/handle-product-requests.mdx) — Walk a vendor's product submission and a follow-up edit through the operator review pipeline — confirm, decline, or send back.
49
50
  - [Import and export products via CSV](content/resources/tutorials/import-export-products.mdx) — Install the product-import-export block and give vendors CSV import and export drawers on their product list.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercurjs/docs",
3
- "version": "2.2.0-canary.41",
3
+ "version": "2.2.0-canary.42",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/mercurjs/mercur",