@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.
- package/dist/api-reference/webhooks-events.md +1 -1
- package/dist/developer/contributing/developing-spree.md +2 -2
- package/dist/developer/core-concepts/metafields.md +1 -1
- package/dist/developer/core-concepts/store-credits-gift-cards.md +3 -5
- package/dist/developer/core-concepts/stores.md +1 -1
- package/dist/developer/customization/configuration.md +8 -8
- package/dist/developer/customization/dependencies.md +9 -9
- package/dist/developer/customization/metadata.md +3 -3
- package/dist/developer/customization/quickstart.md +7 -7
- package/dist/developer/customization/validations.md +1 -1
- package/dist/developer/dashboard/customization/backend.md +7 -4
- package/dist/developer/dashboard/customization/navigation.md +5 -3
- package/dist/developer/dashboard/customization/permissions.md +3 -1
- package/dist/developer/dashboard/customization/quickstart.md +3 -4
- package/dist/developer/dashboard/customization/routes.md +7 -6
- package/dist/developer/dashboard/customization/slots.md +7 -6
- package/dist/developer/dashboard/customization/tables.md +4 -2
- package/dist/developer/dashboard/customization/translations.md +4 -2
- package/dist/developer/dashboard/overview.md +5 -4
- package/dist/developer/dashboard/public-api.md +16 -2
- package/dist/developer/dashboard/recipes/attribute-end-to-end.md +2 -3
- package/dist/developer/dashboard/recipes/custom-form-field.md +12 -6
- package/dist/developer/dashboard/recipes/page-action-button.md +9 -8
- package/dist/developer/dashboard/recipes/sidebar-widget.md +11 -5
- package/dist/developer/dashboard/slots-catalog.md +1 -1
- package/dist/developer/how-to/build-a-marketplace.md +3 -3
- package/dist/developer/how-to/custom-digital-asset-provider.md +1 -1
- package/dist/developer/how-to/custom-document-numbers.md +3 -3
- package/dist/developer/how-to/custom-promotion.md +1 -1
- package/dist/developer/how-to/sell-digital-products.md +1 -1
- package/dist/developer/providers/dam.md +3 -1
- package/dist/developer/providers/erp.md +3 -1
- package/dist/developer/providers/pim.md +3 -1
- package/dist/developer/providers/sso.md +1 -1
- package/dist/developer/tutorial/dashboard.md +307 -0
- package/dist/developer/tutorial/events.md +66 -6
- package/dist/developer/tutorial/introduction.md +42 -13
- package/dist/developer/tutorial/model-and-api.md +187 -0
- package/dist/developer/tutorial/storefront.md +123 -6
- package/dist/developer/tutorial/testing.md +163 -7
- package/package.json +1 -1
- package/dist/developer/tutorial/admin-api.md +0 -13
- package/dist/developer/tutorial/dashboard-plugin.md +0 -14
- package/dist/developer/tutorial/model.md +0 -13
- 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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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,
|
|
20
|
-
|
|
19
|
+
Card,
|
|
20
|
+
CardContent,
|
|
21
|
+
CardHeader,
|
|
22
|
+
CardTitle,
|
|
23
|
+
Empty,
|
|
24
|
+
EmptyDescription,
|
|
21
25
|
Skeleton,
|
|
22
|
-
|
|
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
|
|
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
|
|
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
|
|
3
|
+
description: Marketplace capabilities are part of Spree core — sellers, order splitting, commissions and payouts, without a separate extension.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
This guide covers the open-source core.
|
|
7
7
|
|
|
8
|
-
Spree
|
|
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](
|
|
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
|
|
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.**
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
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
|
|
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
|
|
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
|
-
|
|
8
|
+
There are two ways to consume an event, and they serve different audiences:
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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).
|