@spree/docs 0.1.237 → 0.1.238

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.
Files changed (45) hide show
  1. package/dist/api-reference/webhooks-events.md +1 -1
  2. package/dist/developer/contributing/developing-spree.md +2 -2
  3. package/dist/developer/core-concepts/metafields.md +1 -1
  4. package/dist/developer/core-concepts/store-credits-gift-cards.md +3 -5
  5. package/dist/developer/core-concepts/stores.md +1 -1
  6. package/dist/developer/customization/configuration.md +8 -8
  7. package/dist/developer/customization/dependencies.md +9 -9
  8. package/dist/developer/customization/metadata.md +3 -3
  9. package/dist/developer/customization/quickstart.md +7 -7
  10. package/dist/developer/customization/validations.md +1 -1
  11. package/dist/developer/dashboard/customization/backend.md +7 -4
  12. package/dist/developer/dashboard/customization/navigation.md +5 -3
  13. package/dist/developer/dashboard/customization/permissions.md +3 -1
  14. package/dist/developer/dashboard/customization/quickstart.md +3 -4
  15. package/dist/developer/dashboard/customization/routes.md +7 -6
  16. package/dist/developer/dashboard/customization/slots.md +7 -6
  17. package/dist/developer/dashboard/customization/tables.md +4 -2
  18. package/dist/developer/dashboard/customization/translations.md +4 -2
  19. package/dist/developer/dashboard/overview.md +5 -4
  20. package/dist/developer/dashboard/public-api.md +16 -2
  21. package/dist/developer/dashboard/recipes/attribute-end-to-end.md +2 -3
  22. package/dist/developer/dashboard/recipes/custom-form-field.md +12 -6
  23. package/dist/developer/dashboard/recipes/page-action-button.md +9 -8
  24. package/dist/developer/dashboard/recipes/sidebar-widget.md +11 -5
  25. package/dist/developer/dashboard/slots-catalog.md +1 -1
  26. package/dist/developer/how-to/build-a-marketplace.md +3 -3
  27. package/dist/developer/how-to/custom-digital-asset-provider.md +1 -1
  28. package/dist/developer/how-to/custom-document-numbers.md +3 -3
  29. package/dist/developer/how-to/custom-promotion.md +1 -1
  30. package/dist/developer/how-to/sell-digital-products.md +1 -1
  31. package/dist/developer/providers/dam.md +3 -1
  32. package/dist/developer/providers/erp.md +3 -1
  33. package/dist/developer/providers/pim.md +3 -1
  34. package/dist/developer/providers/sso.md +1 -1
  35. package/dist/developer/tutorial/dashboard.md +307 -0
  36. package/dist/developer/tutorial/events.md +66 -6
  37. package/dist/developer/tutorial/introduction.md +42 -13
  38. package/dist/developer/tutorial/model-and-api.md +187 -0
  39. package/dist/developer/tutorial/storefront.md +123 -6
  40. package/dist/developer/tutorial/testing.md +163 -7
  41. package/package.json +1 -1
  42. package/dist/developer/tutorial/admin-api.md +0 -13
  43. package/dist/developer/tutorial/dashboard-plugin.md +0 -14
  44. package/dist/developer/tutorial/model.md +0 -13
  45. package/dist/developer/tutorial/store-api.md +0 -13
@@ -89,7 +89,7 @@ Add a Rails validation while you're here (`validates :lead_time_days, numericali
89
89
 
90
90
  ```tsx
91
91
  // src/plugins.ts (or a plugin's entry module)
92
- import { defineDashboardPlugin, i18n } from '@spree/dashboard-core'
92
+ import { defineDashboardPlugin, i18n } from '@spree/dashboard'
93
93
  import { LeadTimeCard } from './fields/lead-time-card'
94
94
 
95
95
  i18n.addResourceBundle('en', 'translation', en, true, true)
@@ -124,8 +124,7 @@ The form widget binds to the host form — no `<form>` of its own, no save butto
124
124
 
125
125
  ```tsx
126
126
  // src/fields/lead-time-card.tsx
127
- import { useHostForm } from '@spree/dashboard-core'
128
- import { Card, CardContent, CardHeader, CardTitle, Field, FieldError, FieldLabel, Input } from '@spree/dashboard-ui'
127
+ import { Card, CardContent, CardHeader, CardTitle, Field, FieldError, FieldLabel, Input, useHostForm } from '@spree/dashboard'
129
128
  import { useTranslation } from 'react-i18next'
130
129
 
131
130
  export function LeadTimeCard() {
@@ -31,7 +31,7 @@ When the default widget isn't right — a color, a rating, a structured pair —
31
31
 
32
32
  ```tsx
33
33
  // src/plugins.ts (or a plugin's entry module)
34
- import { type CustomFieldComponentProps, defineDashboardPlugin } from '@spree/dashboard-core'
34
+ import { type CustomFieldComponentProps, defineDashboardPlugin } from '@spree/dashboard'
35
35
 
36
36
  function ColorPicker({ id, value, onChange, ariaLabel }: CustomFieldComponentProps) {
37
37
  return (
@@ -76,7 +76,7 @@ Tell the product form about the field: where its value comes from on load. Every
76
76
 
77
77
  ```tsx
78
78
  // src/plugins.ts (or a plugin's entry module)
79
- import { defineDashboardPlugin } from '@spree/dashboard-core'
79
+ import { defineDashboardPlugin } from '@spree/dashboard'
80
80
 
81
81
  defineDashboardPlugin({
82
82
  formFields: {
@@ -94,11 +94,17 @@ The `product.form_sidebar` slot renders inside the product form. Bind your input
94
94
 
95
95
  ```tsx
96
96
  // src/fields/tech-specs-card.tsx
97
- import { useHostForm } from '@spree/dashboard-core'
98
97
  import {
99
- Card, CardContent, CardHeader, CardTitle,
100
- Field, FieldError, FieldLabel, Textarea,
101
- } from '@spree/dashboard-ui'
98
+ Card,
99
+ CardContent,
100
+ CardHeader,
101
+ CardTitle,
102
+ Field,
103
+ FieldError,
104
+ FieldLabel,
105
+ Textarea,
106
+ useHostForm,
107
+ } from '@spree/dashboard'
102
108
  import { useTranslation } from 'react-i18next'
103
109
 
104
110
  export function TechSpecsCard() {
@@ -17,8 +17,7 @@ This recipe shows both — and how to gate them by permission + resource state.
17
17
 
18
18
  ```tsx
19
19
  // src/actions/send-invoice-button.tsx
20
- import { adminClient, usePermissions, useResourceMutation } from '@spree/dashboard-core'
21
- import { Button } from '@spree/dashboard-ui'
20
+ import { Button, adminClient, usePermissions, useResourceMutation } from '@spree/dashboard'
22
21
  import { Send } from 'lucide-react'
23
22
 
24
23
  interface Props {
@@ -55,7 +54,7 @@ Register it:
55
54
 
56
55
  ```ts
57
56
  // src/plugins.ts (in your dashboard app)
58
- import { defineDashboardPlugin } from '@spree/dashboard-core'
57
+ import { defineDashboardPlugin } from '@spree/dashboard'
59
58
  import { SendInvoiceButton } from './actions/send-invoice-button'
60
59
 
61
60
  defineDashboardPlugin({
@@ -82,8 +81,7 @@ For secondary actions, use `page.actions_dropdown` and render `<DropdownMenuItem
82
81
 
83
82
  ```tsx
84
83
  // src/actions/sync-to-erp-item.tsx
85
- import { adminClient, usePermissions, useResourceMutation } from '@spree/dashboard-core'
86
- import { DropdownMenuItem } from '@spree/dashboard-ui'
84
+ import { DropdownMenuItem, adminClient, usePermissions, useResourceMutation } from '@spree/dashboard'
87
85
  import { RefreshCw } from 'lucide-react'
88
86
 
89
87
  interface Props {
@@ -135,9 +133,12 @@ For destructive or expensive actions, wrap the click in a `<Dialog>`:
135
133
  ```tsx
136
134
  import { useState } from 'react'
137
135
  import {
138
- Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
139
- DropdownMenuItem, Button,
140
- } from '@spree/dashboard-ui'
136
+ adminClient,
137
+ Button,
138
+ Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
139
+ DropdownMenuItem,
140
+ useResourceMutation,
141
+ } from '@spree/dashboard'
141
142
 
142
143
  export function ResetCartItem({ resource }: { resource: { id: string } }) {
143
144
  const [open, setOpen] = useState(false)
@@ -13,13 +13,19 @@ This recipe builds a "Loyalty status" card for the customer detail page: read-on
13
13
  ```tsx
14
14
  // src/widgets/loyalty-status-card.tsx
15
15
  import { useQuery } from '@tanstack/react-query'
16
- import { adminClient, usePermissions, useStore } from '@spree/dashboard-core'
17
16
  import {
17
+ adminClient,
18
18
  Badge,
19
- Card, CardContent, CardHeader, CardTitle,
20
- Empty, EmptyDescription,
19
+ Card,
20
+ CardContent,
21
+ CardHeader,
22
+ CardTitle,
23
+ Empty,
24
+ EmptyDescription,
21
25
  Skeleton,
22
- } from '@spree/dashboard-ui'
26
+ usePermissions,
27
+ useStore,
28
+ } from '@spree/dashboard'
23
29
  import { Trophy } from 'lucide-react'
24
30
 
25
31
  interface LoyaltyRecord {
@@ -94,7 +100,7 @@ Worth noting:
94
100
 
95
101
  ```ts
96
102
  // src/plugins.ts (in your dashboard app)
97
- import { defineDashboardPlugin } from '@spree/dashboard-core'
103
+ import { defineDashboardPlugin } from '@spree/dashboard'
98
104
  import { LoyaltyStatusCard } from './widgets/loyalty-status-card'
99
105
 
100
106
  defineDashboardPlugin({
@@ -13,7 +13,7 @@ If you need a new injection point in a built-in page, open a PR adding `<Slot na
13
13
  Ambient context (`permissions`, `store`, `user` merged into every slot's props) is planned but **not wired up yet** — today slot components receive only the slot-specific context listed below. Until it lands, read those values with the hooks instead:
14
14
 
15
15
  ```tsx
16
- import { usePermissions, useStore } from '@spree/dashboard-core'
16
+ import { usePermissions, useStore } from '@spree/dashboard'
17
17
 
18
18
  function MyWidget({ product }: { product: Product }) {
19
19
  const { permissions } = usePermissions()
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  title: Build a Multi-Vendor Marketplace
3
- description: Marketplace capabilities are part of Spree 6 core — sellers, order splitting, commissions and payouts, without a separate extension.
3
+ description: Marketplace capabilities are part of Spree core — sellers, order splitting, commissions and payouts, without a separate extension.
4
4
  ---
5
5
 
6
- > **NOTE:** Marketplace features are a headline capability of the Spree 6.0 release and are documented here as they land. This guide covers the open-source core.
6
+ This guide covers the open-source core.
7
7
 
8
- Spree 6 brings multi-vendor marketplace support into the open-source core: sellers as first-class records, orders split per seller at completion, a commission engine, and a payout ledger with pluggable payout providers.
8
+ Spree brings multi-vendor marketplace support into the open-source core: sellers as first-class records, orders split per seller at completion, a commission engine, and a payout ledger with pluggable payout providers.
9
9
 
10
10
  ## What you will build
11
11
 
@@ -131,6 +131,6 @@ Spree::DigitalDelivery.new(inline_value: 'ABCD-1234-EFGH', content_type: 'text/p
131
131
 
132
132
  ## Related documentation
133
133
 
134
- - [Digital products](/use-case/digital-products/capabilities) — what the feature does for merchants
134
+ - [Digital products](sell-digital-products.md) — what the feature does for merchants
135
135
  - [Products](../core-concepts/products.md#digital-products) — how digital assets, links, and downloads fit together
136
136
  - [Build a Custom Delivery Rate Provider](custom-delivery-rate-provider.md) — a provider strategy that *does* use an integration, for contrast
@@ -22,7 +22,7 @@ There are two ways to change it. Merchants can reshape **order numbers** from th
22
22
  | Suffix | Ends each order number. Empty by default. |
23
23
  | Start numbering at | The first order number. Defaults to `1001`. |
24
24
 
25
- Sequential numbering counts up: `R1001`, `R1002`, `R1003`. Random numbering produces nine unpredictable digits, which is what Spree did before 6.0. The trade-off is worth stating to merchants: sequential numbers are far easier to read back over the phone, but a customer who orders twice can see how many orders you took in between. Random numbers give that away nothing, at the cost of legibility.
25
+ Sequential numbering counts up: `R1001`, `R1002`, `R1003`. Random numbering produces nine unpredictable digits. The trade-off is worth stating to merchants: sequential numbers are far easier to read back over the phone, but a customer who orders twice can see how many orders you took in between. Random numbers reveal nothing about that, at the cost of legibility.
26
26
 
27
27
  Two rules apply to every change:
28
28
 
@@ -139,9 +139,9 @@ This is not configurable and there is no generator to register. If you need a di
139
139
 
140
140
  Two changes matter.
141
141
 
142
- **Numbers are sequential by default.** Stores upgrading from 5.x keep every existing number untouched, but new documents get sequential numbers starting at 1001. To keep the old behavior, set the store's numbering format to **Random** in Settings → Store → Order numbers.
142
+ **Numbers are sequential by default.** Existing numbers are never rewritten, and new documents get sequential numbers starting at 1001. To keep the old behavior, set the store's numbering format to **Random** in Settings → Store → Order numbers.
143
143
 
144
- **`Spree::Core::NumberGenerator` is deprecated.** Models using it still work through Spree 6.0 and log a deprecation warning. Replace it with the concern:
144
+ **`Spree::Core::NumberGenerator` is deprecated.** Models using it still work and log a deprecation warning, but will stop working in a future release. Replace it with the concern:
145
145
 
146
146
  ```ruby
147
147
  # before
@@ -61,7 +61,7 @@ end
61
61
 
62
62
  The `options` hash passed to `eligible?` can include `:user`, `:email`, and other context from the checkout flow.
63
63
 
64
- > **WARNING:** Accept **both** `Spree::Cart` and `Spree::Order` in `applicable?`. Promotions are evaluated on every cart change, long before the cart becomes an order, and in Spree 6 a cart is its own model rather than an unfinished order. A rule guarding on `Spree::Order` alone silently never applies during checkout — the promotion just appears not to work. Every built-in rule accepts both.
64
+ > **WARNING:** Accept **both** `Spree::Cart` and `Spree::Order` in `applicable?`. Promotions are evaluated on every cart change, long before the cart becomes an order, and a cart is its own model rather than an unfinished order. A rule guarding on `Spree::Order` alone silently never applies during checkout — the promotion just appears not to work. Every built-in rule accepts both.
65
65
 
66
66
  #### Using Preferences
67
67
 
@@ -3,7 +3,7 @@ title: Sell Digital Products
3
3
  description: Digital goods in Spree — a digital delivery profile, instant fulfillment without a shipping address, and delivering files or entitlements after payment.
4
4
  ---
5
5
 
6
- Digital products skip the warehouse: no stock to reserve, no address to collect, no carrier to call. In Spree 6 that behavior comes from the product's delivery profile — assign a digital profile and checkout, fulfillment and the customer experience adapt. This guide walks through selling an uploaded file end to end.
6
+ Digital products skip the warehouse: no stock to reserve, no address to collect, no carrier to call. That behavior comes from the product's delivery profile — assign a digital profile and checkout, fulfillment and the customer experience adapt. This guide walks through selling an uploaded file end to end.
7
7
 
8
8
  For the concepts behind each piece — assets, links, allowances, providers — see [Products → Digital products](../core-concepts/products.md#digital-products). This page is the practical walkthrough.
9
9
 
@@ -3,7 +3,9 @@ title: Product Media & DAM
3
3
  description: Media hosted in your DAM stays in your DAM — Spree stores the address, not a copy of the bytes, and renders it through the same pipeline as uploaded media.
4
4
  ---
5
5
 
6
- > **NOTE:** This page documents functionality landing in an upcoming Spree 6.0 release. The interfaces described here may change before final release.
6
+ ## Coming soon
7
+
8
+ > **NOTE:** The following features are on the open-source roadmap. The interfaces described here may change before release.
7
9
 
8
10
  If your media lives in a digital asset management system or a CDN you already operate, importing copies into Spree doubles your storage and guarantees drift. Externally hosted media makes the address the record: Spree stores a URL, renders it wherever uploaded media renders, and your DAM remains the source of truth for the asset itself.
9
11
 
@@ -3,7 +3,9 @@ title: Connect Your ERP
3
3
  description: Keep your ERP as the source of truth for stock. Spree syncs inventory in, answers availability from its own snapshot, and asks your ERP live only at the moments that matter.
4
4
  ---
5
5
 
6
- > **NOTE:** This page documents functionality landing in an upcoming Spree 6.0 release. The interfaces described here may change before final release.
6
+ ## Coming soon
7
+
8
+ > **NOTE:** The following features are on the open-source roadmap. The interfaces described here may change before release.
7
9
 
8
10
  Your ERP owns inventory truth. Spree's job is to sell against it without keeping a copy that drifts — and without making every product page wait on an ERP round trip.
9
11
 
@@ -3,7 +3,9 @@ title: Connect Your PIM
3
3
  description: Keep product data and pricing in your PIM. Spree syncs content in, renders from its own copy, and can price live from an external source at cart decision moments.
4
4
  ---
5
5
 
6
- > **NOTE:** This page documents functionality landing in an upcoming Spree 6.0 release. The interfaces described here may change before final release.
6
+ ## Coming soon
7
+
8
+ > **NOTE:** The following features are on the open-source roadmap. The interfaces described here may change before release.
7
9
 
8
10
  Your PIM owns product content — names, descriptions, attributes, associations, prices. Spree renders the shopper-facing copy and asks your systems live only where a stale answer would cost money.
9
11
 
@@ -3,7 +3,7 @@ title: Identity & SSO
3
3
  description: Single sign-on with OpenID Connect is built into Spree — connect your existing identity provider for staff and customer authentication without custom code.
4
4
  ---
5
5
 
6
- Enterprises already have an identity provider. Staff accounts live in it, security policy is enforced through it, and a commerce platform that demands its own password database is a liability. Spree 6 ships OpenID Connect support in core.
6
+ Enterprises already have an identity provider. Staff accounts live in it, security policy is enforced through it, and a commerce platform that demands its own password database is a liability. Spree ships OpenID Connect support in core.
7
7
 
8
8
  ## Staff single sign-on
9
9
 
@@ -0,0 +1,307 @@
1
+ ---
2
+ title: Dashboard
3
+ description: Add Brands management to the dashboard of a create-spree-app project — a list page, navigation, a typed Admin API client and a card on the product page.
4
+ ---
5
+
6
+ Staff need a place to manage brands. The back office is a React app that lives in your project at `apps/dashboard/`, and you add screens to it by editing that app's own code.
7
+
8
+ The dashboard talks to the Admin API you generated in step 1. That is the only data source — it never reaches into the Server app’s models or renders server HTML.
9
+
10
+ ## Where this code goes
11
+
12
+ A project created with `create-spree-app` has this shape:
13
+
14
+ ```
15
+ my-store/
16
+ ├── apps/
17
+ │ ├── dashboard/ # the admin SPA — you edit this
18
+ │ │ ├── src/
19
+ │ │ │ ├── main.tsx
20
+ │ │ │ ├── plugins.ts # ← your customizations start here
21
+ │ │ │ └── routeTree.gen.ts
22
+ │ │ ├── package.json
23
+ │ │ └── vite.config.ts
24
+ │ ├── seller-dashboard/
25
+ │ └── storefront/
26
+ └── server/ # the Server app from step 1
27
+ ```
28
+
29
+ `apps/dashboard/src/plugins.ts` ships empty, imported once by `main.tsx` before the dashboard renders. Everything in this step goes there or in files next to it.
30
+
31
+ The file and the `defineDashboardPlugin` function are named for the registries they talk to, not for how the code ships. A distributed package and your own app call the same function with the same options — the only difference is where the file lives.
32
+
33
+ > **NOTE:** Everything here is an ordinary file in your dashboard app, committed and deployed with the rest of your project. Publishing a package is only worth it when you want to reuse the same screens across several stores — the registrations are identical either way, so moving one into a package later is a copy, not a rewrite. See [dashboard plugins](../dashboard/plugins/overview.md).
34
+
35
+ `@spree/dashboard-core` (registries, providers and hooks) and `@spree/dashboard-ui` (the design system) come installed with the dashboard, so you can import from them straight away.
36
+
37
+ ## Step 1: A typed client for the endpoint
38
+
39
+ Wrap the Admin API calls in one file so pages never call `request` directly. `adminClient` carries authentication, retries and error handling for you — see [talking to the backend](../dashboard/customization/backend.md) for custom endpoints, React-Query hooks and error handling:
40
+
41
+ ```ts apps/dashboard/src/brands/client.ts
42
+ import type { PaginatedResponse } from '@spree/admin-sdk'
43
+ import { adminClient } from '@spree/dashboard'
44
+
45
+ export interface Brand {
46
+ id: string
47
+ name: string
48
+ slug: string | null
49
+ active: boolean
50
+ created_at: string
51
+ updated_at: string
52
+ }
53
+
54
+ export interface BrandCreateParams {
55
+ name: string
56
+ slug?: string
57
+ active?: boolean
58
+ }
59
+
60
+ type ListParams = Record<string, string | number | boolean | undefined>
61
+
62
+ export const BrandsClient = {
63
+ list: (params?: ListParams) =>
64
+ adminClient.request<PaginatedResponse<Brand>>('GET', '/brands', { params }),
65
+
66
+ get: (id: string) => adminClient.request<Brand>('GET', `/brands/${id}`),
67
+
68
+ create: (body: BrandCreateParams) =>
69
+ adminClient.request<Brand>('POST', '/brands', { body }),
70
+
71
+ update: (id: string, body: Partial<BrandCreateParams>) =>
72
+ adminClient.request<Brand>('PATCH', `/brands/${id}`, { body }),
73
+
74
+ delete: (id: string) => adminClient.request<void>('DELETE', `/brands/${id}`),
75
+ }
76
+ ```
77
+
78
+ Paths are relative to `/api/v3/admin`, so `'/brands'` reaches the controller from step 1. The `Brand` interface matches what your Admin serializer returns, timestamps included.
79
+
80
+ ## Step 2: Describe the table
81
+
82
+ `defineTable` declares the columns, sorting, search and empty state. The dashboard renders it with the same table chrome as Products and Orders:
83
+
84
+ ```tsx apps/dashboard/src/plugins.ts
85
+ import { RelativeTime, defineTable, i18n } from '@spree/dashboard'
86
+ import type { Brand } from './brands/client'
87
+
88
+ defineTable<Brand>('brands', {
89
+ title: i18n.t('admin.brands.table.title'),
90
+ searchParam: 'search',
91
+ defaultSort: { field: 'name', direction: 'asc' },
92
+ emptyMessage: i18n.t('admin.brands.table.empty'),
93
+ columns: [
94
+ {
95
+ key: 'name',
96
+ label: i18n.t('admin.brands.fields.name'),
97
+ sortable: true,
98
+ filterable: true,
99
+ default: true,
100
+ render: (record) => <span className="font-medium text-foreground">{record.name}</span>,
101
+ },
102
+ {
103
+ key: 'created_at',
104
+ label: i18n.t('admin.brands.fields.created_at'),
105
+ sortable: true,
106
+ default: true,
107
+ render: (record) => <RelativeTime iso={record.created_at} />,
108
+ },
109
+ ],
110
+ })
111
+ ```
112
+
113
+ `name` is `sortable` and `filterable` because the model allowlisted it in step 1. A column that sorts by an attribute the backend does not permit fails at request time, so the two lists have to agree.
114
+
115
+ This is the short version. [Tables](../dashboard/customization/tables.md) documents every field of a [column definition](../dashboard/customization/tables.md#column-definition), plus [bulk actions, row actions and drag reordering](../dashboard/customization/tables.md#bulk-actions-row-actions-reordering) — and how to [add or remove columns on a built-in table](../dashboard/customization/tables.md#add-a-column-to-a-built-in-table) such as products or orders.
116
+
117
+ > **WARNING:** Every visible string goes through i18next — column labels, titles, buttons, empty states. Never hardcode English into a table definition or into JSX, and add each new key to every locale file your project ships. See [translations](../dashboard/customization/translations.md) for the field-key convention and how server-side validation messages are resolved.
118
+
119
+ ## Step 3: Build the list page
120
+
121
+ `ResourceTable` supplies filtering, sorting and pagination against the table you just declared:
122
+
123
+ ```tsx apps/dashboard/src/brands/list-page.tsx
124
+ import { Button, PageHeader, ResourceLayout, ResourceTable, type ResourceSearch } from '@spree/dashboard'
125
+ import { PlusIcon } from 'lucide-react'
126
+ import { useTranslation } from 'react-i18next'
127
+ import { type Brand, BrandsClient } from './client'
128
+
129
+ export function BrandsListPage({ searchParams }: { searchParams: Record<string, unknown> }) {
130
+ const { t } = useTranslation()
131
+
132
+ return (
133
+ <ResourceLayout
134
+ header={
135
+ <PageHeader
136
+ title={t('admin.brands.page.title')}
137
+ // A create form is the natural next step — see the recipes below.
138
+ actions={
139
+ <Button size="sm" disabled>
140
+ <PlusIcon className="size-4" />
141
+ {t('admin.brands.page.new_cta')}
142
+ </Button>
143
+ }
144
+ />
145
+ }
146
+ main={
147
+ <ResourceTable<Brand>
148
+ tableKey="brands"
149
+ queryKey="brands"
150
+ queryFn={(params) => BrandsClient.list(params as Record<string, never>)}
151
+ searchParams={searchParams as ResourceSearch}
152
+ />
153
+ }
154
+ />
155
+ )
156
+ }
157
+ ```
158
+
159
+ ## Step 4: Register navigation, the route and a product card
160
+
161
+ Back in `plugins.ts`, `defineDashboardPlugin` wires the page into the app:
162
+
163
+ ```tsx apps/dashboard/src/plugins.ts
164
+ import { defineDashboardPlugin, i18n } from '@spree/dashboard'
165
+ import { PackageIcon } from 'lucide-react'
166
+ import { BrandsListPage } from './brands/list-page'
167
+ import { BrandCard } from './brands/product-card'
168
+
169
+ defineDashboardPlugin({
170
+ nav: [
171
+ {
172
+ key: 'brands',
173
+ label: i18n.t('admin.brands.nav'),
174
+ path: '/brands',
175
+ icon: PackageIcon,
176
+ position: 50,
177
+ },
178
+ ],
179
+ routes: [{ key: 'brands', path: '/brands', component: BrandsListPage }],
180
+ slots: {
181
+ 'product.form_sidebar': [
182
+ { id: 'brand-card', component: BrandCard as never, position: 250 },
183
+ ],
184
+ },
185
+ })
186
+ ```
187
+
188
+ Three extension points are doing the work, each with its own reference page:
189
+
190
+ | Option | What it does | Full documentation |
191
+ |---|---|---|
192
+ | `nav` | Adds the sidebar entry; `position` places it among the built-in items | [Navigation](../dashboard/customization/navigation.md) — settings sub-nav, removing and patching built-in entries |
193
+ | `routes` | Mounts the page under the authenticated store layout, so it becomes `/:storeId/brands` | [Routes](../dashboard/customization/routes.md#runtime-route-registry-in-app-dynamic) — path params, layout and permission gating |
194
+ | `slots` | Injects a component where the dashboard already renders one | [Slots](../dashboard/customization/slots.md) and the [slots catalog](../dashboard/slots-catalog.md) — every slot and the context it passes |
195
+
196
+ `product.form_sidebar` is what makes brands useful to the person editing a product: the product page knows nothing about brands, and the slot registry is what puts your card there.
197
+
198
+ > **TIP:** Hiding a nav entry or a column behind a permission is [one option away](../dashboard/customization/permissions.md) — but hiding is never authorising. The Admin API still has to refuse the request, which is why step 1 left authorization to you.
199
+
200
+ Showing the current brand is not much use on its own — the point is to change
201
+ it. The product page is one big form with a single Save button, and
202
+ `product.form_sidebar` renders **inside** that form, so the card can own an
203
+ input without owning any save logic.
204
+
205
+ Two pieces make that work. First, tell the form the field exists and where its
206
+ value comes from on load:
207
+
208
+ ```tsx apps/dashboard/src/plugins.ts
209
+ defineDashboardPlugin({
210
+ formFields: {
211
+ // `from` receives the fetched product, or null on the create form.
212
+ product: [{ name: 'brand_id', from: (product) => product?.brand_id ?? null }],
213
+ },
214
+ })
215
+ ```
216
+
217
+ Then render the input, binding it to the host form with `useHostForm()`. No
218
+ `<form>` of your own, no Save button, no mutation — dirty tracking, the PATCH
219
+ payload and re-baselining after save all stay with the page:
220
+
221
+ ```tsx apps/dashboard/src/brands/product-card.tsx
222
+ import {
223
+ Card, CardContent, CardHeader, CardTitle,
224
+ Field, FieldLabel,
225
+ ResourceCombobox,
226
+ useHostForm,
227
+ } from '@spree/dashboard'
228
+ import { Controller } from 'react-hook-form'
229
+ import { useTranslation } from 'react-i18next'
230
+ import { BrandsClient } from './client'
231
+
232
+ export function BrandCard() {
233
+ const { t } = useTranslation()
234
+ const form = useHostForm<{ brand_id: string | null }>()
235
+
236
+ return (
237
+ <Card>
238
+ <CardHeader>
239
+ <CardTitle>{t('admin.brands.product_card.title')}</CardTitle>
240
+ </CardHeader>
241
+ <CardContent>
242
+ <Field>
243
+ <FieldLabel className="sr-only" htmlFor="brand_id">
244
+ {t('admin.brands.product_card.title')}
245
+ </FieldLabel>
246
+ <Controller
247
+ control={form.control}
248
+ name="brand_id"
249
+ render={({ field }) => (
250
+ <ResourceCombobox
251
+ id="brand_id"
252
+ queryKey="product-brand-picker"
253
+ value={field.value ?? ''}
254
+ onChange={(id) => field.onChange(id ?? null)}
255
+ getOptionLabel={(brand) => brand.name}
256
+ placeholder={t('admin.brands.product_card.placeholder')}
257
+ emptyText={t('admin.brands.product_card.none')}
258
+ search={(query) => BrandsClient.list({ name_cont: query })}
259
+ hydrate={(ids) => BrandsClient.list({ id_in: ids })}
260
+ />
261
+ )}
262
+ />
263
+ </Field>
264
+ </CardContent>
265
+ </Card>
266
+ )
267
+ }
268
+ ```
269
+
270
+ `ResourceCombobox` searches the API as the merchant types rather than loading
271
+ every brand up front, which is what keeps the picker usable on a catalogue
272
+ with thousands of them. `hydrate` resolves the saved ID back to a name so the
273
+ trigger reads correctly on first render.
274
+
275
+ For this to persist, the Admin API has to accept `brand_id` on the product —
276
+ the `resource_permitted_attributes` half of the association you set up in
277
+ step 1.
278
+
279
+ > **NOTE:** A field that merchants should be able to define themselves, rather than one
280
+ > your code adds as a column, is usually better as a [custom field](../core-concepts/metafields.md) — no code at all. See [adding a custom form field](../dashboard/recipes/custom-form-field.md) for both paths.
281
+
282
+ ## Step 5: Run it
283
+
284
+ `spree dev` runs the Server app and the dashboard together:
285
+
286
+ ```bash
287
+ spree dev
288
+ ```
289
+
290
+ Open the dashboard at `http://localhost:5173`. **Brands** appears in the sidebar, the list reads your Admin API endpoint from step 1, and the product edit page shows the card.
291
+
292
+ > **TIP:** Adding a create or edit form next? Wrap `handleSubmit` in a try/catch that calls `mapSpreeErrorsToForm` so 422 responses land on the right fields, and gate any delete that fires straight from a click behind `useConfirm()` with `variant: 'destructive'`.
293
+
294
+ ## Going further
295
+
296
+ This step used a handful of the dashboard's extension points. The reference documentation covers the rest:
297
+
298
+
299
+ - [Customization quickstart](../dashboard/customization/quickstart.md) — The same ground in five minutes, without the Brands feature around it
300
+ - [Public API](../dashboard/public-api.md) — Every component, hook, registry and provider you can safely import
301
+ - [Concepts](../dashboard/concepts.md) — The mental model — what each layer does and where your code fits
302
+ - [Recipes](../dashboard/recipes/attribute-end-to-end.md) — Worked examples: a custom form field, a page action, a sidebar widget
303
+
304
+
305
+ ## Next step
306
+
307
+ Staff can manage brands. Now show them to customers: [Storefront](storefront.md).
@@ -1,12 +1,72 @@
1
1
  ---
2
2
  title: Events
3
- description: Publish lifecycle events for FAQs and react to them — subscribers inside the app, webhooks for systems outside it.
3
+ description: Publish lifecycle events for Brands and react to them — subscribers inside your Spree app, outbound webhooks for systems you do not deploy code into.
4
4
  ---
5
5
 
6
- Features rarely live alone — something usually needs to know when data changes. This chapter publishes lifecycle events for FAQs and consumes them both ways.
6
+ Features rarely live alone. A search index needs to know when a brand is renamed; a PIM wants to hear about new ones. In Spree the integration surface is the **events system**: models publish events as things happen, and you react without touching core code.
7
7
 
8
- ## What this chapter covers
8
+ There are two ways to consume an event, and they serve different audiences:
9
9
 
10
- - Publishing lifecycle events from the model
11
- - A subscriber reacting inside the application
12
- - Delivering the event to an external system as a webhook
10
+ | Mechanism | Code lives | Best for |
11
+ |---|---|---|
12
+ | **Subscriber** | In your Spree app | Calling external APIs with your own client code, internal side effects, anything needing app context |
13
+ | **Webhook** | In the external system | Letting a third party receive HTTP callbacks — no backend code in your app, endpoints managed from the dashboard |
14
+
15
+ ## Step 1: Publish lifecycle events
16
+
17
+ Core models publish `*.created`, `*.updated` and `*.deleted` automatically. Your models do too, with one line:
18
+
19
+ ```ruby server/app/models/spree/brand.rb
20
+ publishes_lifecycle_events
21
+ ```
22
+
23
+ Now `brand.created`, `brand.updated` and `brand.deleted` fire after the matching transaction commits. The payload is the record serialized with the v3 serializer you generated in step 1 — the events system resolves it by naming convention. Every consumer sees the same JSON shape your API serves, which is the point: one contract, not two.
24
+
25
+ ## Step 2: React inside the app
26
+
27
+ Generate a subscriber:
28
+
29
+ ```bash
30
+ spree generate subscriber BrandSync brand.created brand.updated
31
+ ```
32
+
33
+ This creates the subscriber, a spec stub, and — crucially — **registers it** in `config/initializers/spree.rb`. Subscribers are not auto-discovered: one that never gets appended to `Spree.subscribers` is a silent no-op, which is why the generator owns that step. Re-runs are idempotent.
34
+
35
+ Fill in the handler:
36
+
37
+ ```ruby server/app/subscribers/brand_sync_subscriber.rb
38
+ def handle(event)
39
+ SearchIndexer.upsert_brand(id: event.payload['id'], name: event.payload['name'])
40
+ end
41
+ ```
42
+
43
+ Two things worth understanding:
44
+
45
+ - **The payload is the record, already serialized.** It is the same JSON your API returns, so `event.payload['name']` is there without a database query. IDs in it are public ones (`brand_k5nR8xLq`); when you do need the full record, look it up with `Spree::Brand.find_by_prefix_id(event.payload['id'])` rather than treating the id as a row number.
46
+ - **Subscribers run asynchronously by default.** Each `handle` call is an ActiveJob, so a slow third-party API never blocks the request that triggered it. Pass `subscribes_to 'brand.created', async: false` only when you genuinely need it synchronous.
47
+
48
+ Restart the server, rename a brand in the dashboard, and watch the job run.
49
+
50
+ ## Step 3: Publish a custom event
51
+
52
+ Lifecycle events cover persistence. Custom events express *domain* moments — something meaningful happened, not just a row changed:
53
+
54
+ ```ruby
55
+ brand.publish_event('brand.featured')
56
+ ```
57
+
58
+ The payload defaults to the serializer output; pass your own hash as a second argument when the event needs different data. Subscribers consume it exactly like any built-in event.
59
+
60
+ ## Step 4: Deliver to external systems with webhooks
61
+
62
+ When the consumer is a system you do not deploy your own code into, use a webhook instead. In the dashboard go to **Settings → Webhooks**, add an endpoint URL, and select the events to deliver — `brand.created` and anything else publishing in your store.
63
+
64
+ Each delivery is an HTTP POST carrying the event name and the same serialized payload. The endpoint's **signing secret is shown once when you create it**: store it in the receiving system and verify every request against it, so the receiver can prove the call came from your store.
65
+
66
+ Deliveries are retried with exponential backoff, and the dashboard shows the attempt history per endpoint, which is where to look when a third party says they never received something.
67
+
68
+ > **TIP:** Webhooks need no code in your app at all. Reach for a subscriber only when the reaction needs application context or credentials you would rather not hand to a third party. See [events](../core-concepts/events.md) and [webhooks](../core-concepts/webhooks.md).
69
+
70
+ ## Next step
71
+
72
+ The feature is complete across every layer. Now prove it works: [Testing](testing.md).