@spree/docs 0.1.121 → 0.1.123

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 (30) hide show
  1. package/dist/api-reference/admin-api/endpoints.md +1 -1
  2. package/dist/api-reference/store.yaml +225 -204
  3. package/dist/developer/dashboard/concepts.md +103 -0
  4. package/dist/developer/dashboard/customization/backend.md +126 -0
  5. package/dist/developer/dashboard/customization/navigation.md +211 -0
  6. package/dist/developer/dashboard/customization/permissions.md +123 -0
  7. package/dist/developer/dashboard/customization/quickstart.md +100 -0
  8. package/dist/developer/dashboard/customization/routes.md +187 -0
  9. package/dist/developer/dashboard/customization/slots.md +114 -0
  10. package/dist/developer/dashboard/customization/tables.md +167 -0
  11. package/dist/developer/dashboard/customization/translations.md +96 -0
  12. package/dist/developer/dashboard/deployment.md +80 -0
  13. package/dist/developer/dashboard/overview.md +105 -0
  14. package/dist/developer/dashboard/plugins/distributing.md +137 -0
  15. package/dist/developer/dashboard/plugins/overview.md +46 -0
  16. package/dist/developer/dashboard/plugins/publishing.md +142 -0
  17. package/dist/developer/dashboard/plugins/scaffolding.md +133 -0
  18. package/dist/developer/dashboard/public-api.md +241 -0
  19. package/dist/developer/dashboard/recipes/attribute-end-to-end.md +212 -0
  20. package/dist/developer/dashboard/recipes/custom-form-field.md +174 -0
  21. package/dist/developer/dashboard/recipes/page-action-button.md +189 -0
  22. package/dist/developer/dashboard/recipes/sidebar-widget.md +147 -0
  23. package/dist/developer/dashboard/slots-catalog.md +173 -0
  24. package/dist/developer/sdk/admin/extending.md +70 -0
  25. package/dist/developer/sdk/admin/quickstart.md +3 -3
  26. package/dist/developer/tutorial/admin.md +2 -0
  27. package/dist/developer/tutorial/api.md +2 -0
  28. package/dist/developer/tutorial/extending-models.md +2 -0
  29. package/dist/developer/tutorial/model.md +2 -0
  30. package/package.json +1 -1
@@ -0,0 +1,189 @@
1
+ ---
2
+ title: Add a page action button
3
+ sidebarTitle: Page action button
4
+ description: Add a primary action (e.g., "Send invoice", "Sync to ERP") to a built-in detail page's header — both as a top-right button and as a dropdown item.
5
+ ---
6
+
7
+ Built-in detail pages render `<PageHeader>` at the top with primary action buttons on the right and a more-actions (`⋯`) dropdown next to them. Both are slot-based — you can inject your own buttons or menu items without touching the host page.
8
+
9
+ Two slots cover the surface:
10
+
11
+ - `page.actions` — the right-aligned action zone (buttons, badges)
12
+ - `page.actions_dropdown` — the dropdown menu
13
+
14
+ This recipe shows both — and how to gate them by permission + resource state. (Strings are hardcoded for brevity; real buttons should go through i18n — see [Translations](../customization/translations.md).)
15
+
16
+ ## A button in the actions row
17
+
18
+ ```tsx
19
+ // src/actions/send-invoice-button.tsx
20
+ import { adminClient, usePermissions, useResourceMutation } from '@spree/dashboard-core'
21
+ import { Button } from '@spree/dashboard-ui'
22
+ import { Send } from 'lucide-react'
23
+
24
+ interface Props {
25
+ resource: { id: string; state: string } // Order
26
+ }
27
+
28
+ export function SendInvoiceButton({ resource }: Props) {
29
+ const { permissions } = usePermissions()
30
+ const mutation = useResourceMutation({
31
+ mutationFn: () =>
32
+ adminClient.request('POST', `/orders/${resource.id}/send_invoice`),
33
+ successMessage: 'Invoice sent',
34
+ })
35
+
36
+ // Only show to users who can act, and only on completed orders.
37
+ if (!permissions.can('update', 'Spree::Order')) return null
38
+ if (resource.state !== 'complete') return null
39
+
40
+ return (
41
+ <Button
42
+ variant="outline"
43
+ size="sm"
44
+ onClick={() => mutation.mutate()}
45
+ disabled={mutation.isPending}
46
+ >
47
+ <Send className="size-4" />
48
+ Send invoice
49
+ </Button>
50
+ )
51
+ }
52
+ ```
53
+
54
+ Register it:
55
+
56
+ ```ts
57
+ // src/plugins.ts (in your dashboard app)
58
+ import { defineDashboardPlugin } from '@spree/dashboard-core'
59
+ import { SendInvoiceButton } from './actions/send-invoice-button'
60
+
61
+ defineDashboardPlugin({
62
+ slots: {
63
+ 'page.actions': [{
64
+ id: 'send-invoice',
65
+ component: SendInvoiceButton as never,
66
+ position: 50,
67
+ }],
68
+ },
69
+ })
70
+ ```
71
+
72
+ Two layers of gating, both inside the component:
73
+
74
+ 1. **Permission check** — `usePermissions()`; render `null` when the user can't act.
75
+ 2. **Resource-state check** — the user *can* send invoices, but this specific order isn't ready yet.
76
+
77
+ (The slot entry's `if` predicate receives only the slot's own context — `{ resource }` here — so use it for resource-state conditions, and keep permission checks in the component. See [Slots](../customization/slots.md).)
78
+
79
+ ## A dropdown menu item
80
+
81
+ For secondary actions, use `page.actions_dropdown` and render `<DropdownMenuItem>`:
82
+
83
+ ```tsx
84
+ // src/actions/sync-to-erp-item.tsx
85
+ import { adminClient, usePermissions, useResourceMutation } from '@spree/dashboard-core'
86
+ import { DropdownMenuItem } from '@spree/dashboard-ui'
87
+ import { RefreshCw } from 'lucide-react'
88
+
89
+ interface Props {
90
+ resource: { id: string }
91
+ }
92
+
93
+ export function SyncToErpItem({ resource }: Props) {
94
+ const { permissions } = usePermissions()
95
+ const mutation = useResourceMutation({
96
+ mutationFn: () => adminClient.request('POST', `/orders/${resource.id}/sync_to_erp`),
97
+ successMessage: 'Synced',
98
+ })
99
+
100
+ if (!permissions.can('manage', 'Spree::Order')) return null
101
+
102
+ return (
103
+ <DropdownMenuItem
104
+ onSelect={(event) => {
105
+ event.preventDefault()
106
+ mutation.mutate()
107
+ }}
108
+ disabled={mutation.isPending}
109
+ >
110
+ <RefreshCw className="size-4" />
111
+ Sync to ERP
112
+ </DropdownMenuItem>
113
+ )
114
+ }
115
+ ```
116
+
117
+ ```ts
118
+ defineDashboardPlugin({
119
+ slots: {
120
+ 'page.actions_dropdown': [{
121
+ id: 'sync-to-erp',
122
+ component: SyncToErpItem as never,
123
+ position: 50,
124
+ }],
125
+ },
126
+ })
127
+ ```
128
+
129
+ The `event.preventDefault()` keeps the dropdown open if the mutation fails — without it, the menu closes on click and the toast appears with no context.
130
+
131
+ ## Confirm before acting
132
+
133
+ For destructive or expensive actions, wrap the click in a `<Dialog>`:
134
+
135
+ ```tsx
136
+ import { useState } from 'react'
137
+ import {
138
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
139
+ DropdownMenuItem, Button,
140
+ } from '@spree/dashboard-ui'
141
+
142
+ export function ResetCartItem({ resource }: { resource: { id: string } }) {
143
+ const [open, setOpen] = useState(false)
144
+ const mutation = useResourceMutation({
145
+ mutationFn: () => adminClient.request('POST', `/orders/${resource.id}/reset_cart`),
146
+ successMessage: 'Cart reset',
147
+ onSuccess: () => setOpen(false),
148
+ })
149
+
150
+ return (
151
+ <>
152
+ <DropdownMenuItem
153
+ onSelect={(event) => {
154
+ event.preventDefault()
155
+ setOpen(true)
156
+ }}
157
+ >
158
+ Reset cart
159
+ </DropdownMenuItem>
160
+ <Dialog open={open} onOpenChange={setOpen}>
161
+ <DialogContent>
162
+ <DialogHeader>
163
+ <DialogTitle>Reset this cart?</DialogTitle>
164
+ </DialogHeader>
165
+ <p>All line items will be cleared. This cannot be undone.</p>
166
+ <DialogFooter>
167
+ <Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
168
+ <Button variant="destructive" onClick={() => mutation.mutate()}>
169
+ Reset
170
+ </Button>
171
+ </DialogFooter>
172
+ </DialogContent>
173
+ </Dialog>
174
+ </>
175
+ )
176
+ }
177
+ ```
178
+
179
+ ## Context
180
+
181
+ The `resource` prop is whatever the host page passed to `<PageHeader resource={…} />`. On the order detail page it's an `Order`; on a product page it's a `Product`. The slot doesn't statically know which — see [Slots](../customization/slots.md) for the typing escape hatches.
182
+
183
+ For the current store, user, or permissions, call the hooks inside your component — `useStore()`, `useAuth()`, `usePermissions()` from `@spree/dashboard-core`.
184
+
185
+ ## Reference
186
+
187
+ - [`page.actions` / `page.actions_dropdown`](../slots-catalog.md#page-header-slots) in the slots catalog
188
+ - [`useResourceMutation`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/hooks/use-resource-mutation.ts) — 422-aware mutation hook
189
+ - [Slots](../customization/slots.md) — registry primer
@@ -0,0 +1,147 @@
1
+ ---
2
+ title: Add a sidebar widget
3
+ sidebarTitle: Sidebar widget
4
+ description: Add an info card to a built-in detail page's sidebar — pulling its own data, gated by permissions, with loading and empty states.
5
+ ---
6
+
7
+ The product, order, and customer detail pages each render a `<Slot>` in their right-hand sidebar — typically named `<resource>.form_sidebar`. Widgets rendered into these slots see the current record and can fetch their own data without disturbing the host page.
8
+
9
+ This recipe builds a "Loyalty status" card for the customer detail page: read-only, fetches the customer's points + tier, shows a spinner while loading, an empty state when there's no data, and hides itself for staff without the right permission.
10
+
11
+ ## The component
12
+
13
+ ```tsx
14
+ // src/widgets/loyalty-status-card.tsx
15
+ import { useQuery } from '@tanstack/react-query'
16
+ import { adminClient, usePermissions, useStore } from '@spree/dashboard-core'
17
+ import {
18
+ Badge,
19
+ Card, CardContent, CardHeader, CardTitle,
20
+ Empty, EmptyDescription,
21
+ Skeleton,
22
+ } from '@spree/dashboard-ui'
23
+ import { Trophy } from 'lucide-react'
24
+
25
+ interface LoyaltyRecord {
26
+ customer_id: string
27
+ points: number
28
+ tier: 'bronze' | 'silver' | 'gold'
29
+ }
30
+
31
+ interface Props {
32
+ // The customer detail page's slot context — see the slots catalog.
33
+ customer: { id: string }
34
+ }
35
+
36
+ export function LoyaltyStatusCard({ customer }: Props) {
37
+ const { storeId } = useStore()
38
+ const { permissions } = usePermissions()
39
+ const canRead = permissions.can('read', 'MyApp::LoyaltyRecord')
40
+ const { data, isLoading } = useQuery({
41
+ queryKey: ['loyalty', storeId, customer.id],
42
+ queryFn: () =>
43
+ adminClient.request<{ data: LoyaltyRecord | null }>(
44
+ 'GET',
45
+ `/customers/${customer.id}/loyalty`,
46
+ ),
47
+ staleTime: 30_000,
48
+ enabled: canRead,
49
+ })
50
+
51
+ // Hide entirely for staff without the permission — same behavior as
52
+ // core's permission-gated cards.
53
+ if (!canRead) return null
54
+
55
+ return (
56
+ <Card>
57
+ <CardHeader>
58
+ <CardTitle className="flex items-center gap-2">
59
+ <Trophy className="size-4" />
60
+ Loyalty status
61
+ </CardTitle>
62
+ </CardHeader>
63
+ <CardContent>
64
+ {isLoading ? (
65
+ <Skeleton className="h-8 w-32" />
66
+ ) : !data?.data ? (
67
+ <Empty>
68
+ <EmptyDescription>No loyalty record</EmptyDescription>
69
+ </Empty>
70
+ ) : (
71
+ <div className="space-y-2">
72
+ <Badge variant={tierVariant(data.data.tier)}>{data.data.tier}</Badge>
73
+ <p className="text-sm text-muted-foreground">{data.data.points} points</p>
74
+ </div>
75
+ )}
76
+ </CardContent>
77
+ </Card>
78
+ )
79
+ }
80
+
81
+ function tierVariant(tier: LoyaltyRecord['tier']) {
82
+ return tier === 'gold' ? 'default' : 'secondary'
83
+ }
84
+ ```
85
+
86
+ Worth noting:
87
+
88
+ - **`storeId` in the query key.** The dashboard is multi-store; without this, switching stores leaves stale data on screen.
89
+ - **`staleTime: 30_000`** keeps the data fresh enough for a sidebar widget without re-fetching on every focus event.
90
+ - **Three states.** Loading (`<Skeleton>`), empty (`<Empty>`), and loaded. Skipping the empty state ships a card that says "0 points" when really the customer has never been enrolled — different meaning, different UX.
91
+ - **Strings are hardcoded for brevity.** Real widgets should go through i18n — see [Translations](../customization/translations.md).
92
+
93
+ ## Register the widget
94
+
95
+ ```ts
96
+ // src/plugins.ts (in your dashboard app)
97
+ import { defineDashboardPlugin } from '@spree/dashboard-core'
98
+ import { LoyaltyStatusCard } from './widgets/loyalty-status-card'
99
+
100
+ defineDashboardPlugin({
101
+ slots: {
102
+ 'customer.form_sidebar': [{
103
+ id: 'loyalty-status',
104
+ component: LoyaltyStatusCard as never,
105
+ position: 50,
106
+ }],
107
+ },
108
+ })
109
+ ```
110
+
111
+ The permission gate lives *inside* the component (`usePermissions`) rather than in an `if` predicate on the entry — the predicate only receives the slot context today, not ambient permissions (see the [slots catalog](../slots-catalog.md#ambient-context)).
112
+
113
+ `position: 50` puts the card near the top of the sidebar (built-in cards typically use 100/200/300). Pick a number based on whether your widget is more or less important than the others — for a "status" card, near the top is right.
114
+
115
+ ## When to fetch in the widget vs. read from the host
116
+
117
+ The pattern above fetches its own data. The alternative is to read it from the resource the host passes:
118
+
119
+ ```tsx
120
+ function MyCard({ customer }: { customer: Customer }) {
121
+ return <div>{customer.loyalty_points}</div> // host already loaded it
122
+ }
123
+ ```
124
+
125
+ Choose based on **whether the data is hot path for the rest of the page**:
126
+
127
+ | Data | Approach |
128
+ |---|---|
129
+ | Already on the host's serializer (e.g., `customer.tags`) | Read from the slot context — no extra HTTP |
130
+ | Conditional, expensive, or owned by your plugin | Fetch in the widget |
131
+ | Sometimes-needed (e.g., gated by feature flag) | Fetch in the widget |
132
+
133
+ A widget that always fetches is wasteful when the host already has the data. A widget that reads from the host but the host doesn't serialize is broken. Match the pattern to the source of truth.
134
+
135
+ ## Loading states for the host page
136
+
137
+ The built-in detail pages render their sidebar only after the record has loaded, so the context record is always set when your widget mounts. If you register into a slot where that's not guaranteed (check the slots catalog entry), guard defensively:
138
+
139
+ ```tsx
140
+ if (!customer) return null // or a <Skeleton> to reserve layout space
141
+ ```
142
+
143
+ ## Reference
144
+
145
+ - [Slots catalog → form sidebars](../slots-catalog.md) — every page-level sidebar slot
146
+ - [`useQuery`](https://tanstack.com/query/v5/docs/framework/react/reference/useQuery) — query key conventions
147
+ - [`@spree/dashboard` README](https://github.com/spree/spree/blob/main/packages/dashboard/README.md) — the multi-store model and why `storeId` belongs in the key
@@ -0,0 +1,173 @@
1
+ ---
2
+ title: Slots catalog
3
+ sidebarTitle: Slots catalog
4
+ description: Every named slot the dashboard renders today. Each entry lists the host page, the slot name, and the context the slot's components receive.
5
+ ---
6
+
7
+ This is the canonical list of slots the dashboard currently exposes. The source of truth is the `<Slot name="…">` call sites in the dashboard source (linked under Reference below); each entry here records the host page, the slot name, and the context your component receives.
8
+
9
+ If you need a new injection point in a built-in page, open a PR adding `<Slot name="..." context={...} />` and a documentation entry here — that's the contract for new slots.
10
+
11
+ ## Ambient context
12
+
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
+
15
+ ```tsx
16
+ import { usePermissions, useStore } from '@spree/dashboard-core'
17
+
18
+ function MyWidget({ product }: { product: Product }) {
19
+ const { permissions } = usePermissions()
20
+ const { store } = useStore()
21
+ if (!permissions.can('read', 'MyApp::LoyaltyRecord')) return null
22
+ // ...
23
+ }
24
+ ```
25
+
26
+ The same applies to an entry's `if` predicate: it receives the slot context, but not `permissions` — gate inside the component for now.
27
+
28
+ ## Page header slots
29
+
30
+ Rendered by `<PageHeader>` (`@spree/dashboard-core/components/page-header.tsx`) at the top of every detail and list page that uses the shared chrome.
31
+
32
+ ### `page.actions`
33
+
34
+ | Where | Top-right of `<PageHeader>`, left of any explicit `actions` prop |
35
+ |---|---|
36
+ | Use | Add primary action buttons (e.g., a "Refund" button on the order detail page) |
37
+ | Context | `{ resource, ...slotContext }` — `resource` is the current page's primary record, when the host passed one to `<PageHeader resource={...} />`. May be `undefined` on list pages. |
38
+
39
+ ### `page.actions_dropdown`
40
+
41
+ | Where | Inside the more-actions (`⋯`) dropdown menu, above the auto-rendered Copy ID / Delete items |
42
+ |---|---|
43
+ | Use | Add menu items (`<DropdownMenuItem>`) for secondary actions |
44
+ | Context | `{ resource, ...slotContext }` — same as `page.actions` |
45
+
46
+ ## Detail-page form slots
47
+
48
+ The resource detail pages each render a slot below their built-in cards. The context key matches the resource name.
49
+
50
+ **Host form:** on pages marked *host form: yes*, the slot renders inside the page's own `<form>` and the form context is exposed — widgets can bind inputs via [`useHostForm()`](recipes/custom-form-field.md) that hydrate, dirty-track, and save with the page's Save button. On pages without one, widgets own their persistence (use `useOptionalHostForm()` to adapt).
51
+
52
+ ### `product.form_sidebar`
53
+
54
+ | Where | Product detail page (`products/$productId`), end of the sidebar column |
55
+ |---|---|
56
+ | Use | Add a card scoped to the product being edited (e.g., a "Brand" picker, wishlist stats, sync status from an external system) |
57
+ | Context | `{ product }` — the full `Product` record from the Admin API |
58
+ | Host form | **Yes** — form key `product` |
59
+
60
+ ### `category.form_sidebar`
61
+
62
+ | Where | Category detail page (`products/categories/$categoryId`), end of the sidebar column |
63
+ |---|---|
64
+ | Use | Category-scoped cards — merchandising rules, feed settings, external sync state |
65
+ | Context | `{ category }` — the `Category` record (may be briefly `undefined` while refetching) |
66
+ | Host form | **Yes** — form key `category` |
67
+
68
+ ### `store.form_main`
69
+
70
+ | Where | Store settings page (`settings/store`), end of the main column |
71
+ |---|---|
72
+ | Use | Store-level settings a plugin owns — integration toggles, account linking |
73
+ | Context | `{ store }` — the full `Store` record |
74
+ | Host form | **Yes** — form key `store` |
75
+
76
+ ### `order.form_sidebar`
77
+
78
+ | Where | Order detail page (`orders/$orderId`), end of the sidebar column |
79
+ |---|---|
80
+ | Use | Fraud-check status, shipping-integration state, loyalty points earned |
81
+ | Context | `{ order }` — the full `Order` record |
82
+ | Host form | No — widgets save via their own API calls |
83
+
84
+ ### `customer.form_sidebar`
85
+
86
+ | Where | Customer detail page (`customers/$customerId`), end of the sidebar column |
87
+ |---|---|
88
+ | Use | Loyalty status, support-ticket summary, external CRM links |
89
+ | Context | `{ customer }` — the full `Customer` record |
90
+ | Host form | No — the page edits through sheets; widgets save via their own API calls |
91
+
92
+ ## Page tabs slot
93
+
94
+ ### `page.tabs` (default)
95
+
96
+ Rendered by `<PageTabs>` at the right edge of any tabbed sub-nav.
97
+
98
+ | Where | After the built-in tab strip |
99
+ |---|---|
100
+ | Use | Append your own tab(s) to a sub-route — typically for plugin-owned views inside an existing resource (e.g., "Returns" tab on the order detail) |
101
+ | Context | `{ tabs, ...slotContext }` — `tabs` is the array of built-in tabs (so you can inspect or filter by them) |
102
+
103
+ Some pages override `slotName` to scope tabs to a single resource (e.g., `slotName="order.tabs"`). The catalog will grow these as we wire them up; today only the default `page.tabs` name is in production use.
104
+
105
+ ## Payment method editor slots (dynamic)
106
+
107
+ Used by `<PaymentMethodForm>` to let payment-provider plugins replace pieces of the editor sheet. The slot names are computed per provider type (`stripe`, `bogus`, …), so registering against the right name is what hooks your editor in.
108
+
109
+ ### `payment_method.guide.<providerType>`
110
+
111
+ | Where | Above the preferences form |
112
+ |---|---|
113
+ | Use | Banner explaining what the integration does, what credentials to use, links to provider docs |
114
+ | Context | `PaymentMethodEditorContext` (see below) |
115
+
116
+ ### `payment_method.form.<providerType>`
117
+
118
+ | Where | In place of the auto-generated preferences form |
119
+ |---|---|
120
+ | Use | Render a custom React form when the provider's preferences are too complex for the generated UI (multi-step OAuth, environment switchers, …) |
121
+ | Context | `PaymentMethodEditorContext` |
122
+
123
+ ### `payment_method.actions.<providerType>`
124
+
125
+ | Where | Sheet footer, before Save/Cancel |
126
+ |---|---|
127
+ | Use | Provider-specific action buttons — "Test connection", "Rotate keys", "Open dashboard in provider" |
128
+ | Context | `PaymentMethodEditorContext` |
129
+
130
+ ### `PaymentMethodEditorContext`
131
+
132
+ ```ts
133
+ interface PaymentMethodEditorContext {
134
+ mode: 'create' | 'edit'
135
+ type: string // STI shorthand (`stripe`, `bogus`, …)
136
+ paymentMethod: PaymentMethod | null // null in create mode
137
+ preferenceSchema: PreferenceField[]
138
+ preferences: Record<string, unknown>
139
+ onPreferencesChange: (next: Record<string, unknown>) => void
140
+ form: UseFormReturn<PaymentMethodFormValues> // RHF instance for top-level fields
141
+ }
142
+ ```
143
+
144
+ Helpers for building the slot name:
145
+
146
+ ```ts
147
+ import {
148
+ paymentMethodGuideSlot,
149
+ paymentMethodFormSlot,
150
+ paymentMethodActionsSlot,
151
+ } from '@spree/dashboard/components/spree/payment-method-editors/types'
152
+
153
+ paymentMethodFormSlot('stripe') // → "payment_method.form.stripe"
154
+ ```
155
+
156
+ Use these instead of constructing the string yourself, so a rename in one place doesn't silently break your registration.
157
+
158
+ ## Adding a new slot
159
+
160
+ If a built-in page should expose a new injection point:
161
+
162
+ 1. Pick a name (`<resource>.<area>`, e.g., `order.timeline`)
163
+ 2. Add `<Slot name="..." context={{ resource: order, /* ... */ }} />` at the call site
164
+ 3. Document the slot here — host, intent, context shape
165
+ 4. Open the PR
166
+
167
+ The slot is not "live" until the docs land — without an entry here, no plugin author knows it exists.
168
+
169
+ ## Reference
170
+
171
+ - [`<Slot>` source](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/components/slot.tsx)
172
+ - [`slot-registry`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/lib/slot-registry.ts) — `registerSlot`, `removeSlot`, `updateSlot`, `useSlotEntries`
173
+ - [Slots customization page](customization/slots.md) — how to register against a slot
@@ -0,0 +1,70 @@
1
+ ---
2
+ title: Custom Admin Endpoints
3
+ sidebarTitle: Custom Endpoints
4
+ description: Call custom Admin API endpoints using the SDK's built-in request method
5
+ ---
6
+
7
+ The Admin SDK client exposes a `request` method — the same function that powers `client.products.list()`, `client.orders.get()`, and every other built-in resource. Use it to call any Admin API endpoint, including ones added by an extension gem that doesn't ship its own client.
8
+
9
+ ```typescript
10
+ import { createAdminClient } from '@spree/admin-sdk'
11
+ import type { PaginatedResponse } from '@spree/admin-sdk'
12
+
13
+ const client = createAdminClient({
14
+ baseUrl: 'https://api.mystore.com',
15
+ token: 'sk_YOUR_SECRET_KEY', // or a JWT from /auth/login
16
+ })
17
+
18
+ interface Brand {
19
+ id: string
20
+ name: string
21
+ slug: string | null
22
+ description: string | null
23
+ logo_url: string | null
24
+ }
25
+
26
+ // Paths are relative to /api/v3/admin
27
+ const brands = await client.request<PaginatedResponse<Brand>>('GET', '/brands')
28
+ const brand = await client.request<Brand>('GET', '/brands/brand_2X9aQf7kEw')
29
+ ```
30
+
31
+ `client.request` uses the same auth headers (secret API key *or* JWT, whichever was configured at client creation), retry logic, and base URL as every other resource on the client. The 401 → refresh → retry handshake that's wired up for the dashboard's user session applies to your custom endpoint too.
32
+
33
+ ## Mutations
34
+
35
+ POST/PATCH/DELETE work the same way — pass `{ body }` for the request payload:
36
+
37
+ ```typescript
38
+ const created = await client.request<Brand>('POST', '/brands', {
39
+ body: { name: 'Acme', slug: 'acme', description: 'Quality goods since 1953' },
40
+ })
41
+
42
+ const updated = await client.request<Brand>('PATCH', `/brands/${created.id}`, {
43
+ body: { description: 'New tagline' },
44
+ })
45
+
46
+ await client.request<void>('DELETE', `/brands/${created.id}`)
47
+ ```
48
+
49
+ ## Ransack filters and pagination
50
+
51
+ Standard list params work — pass them as the third argument's `params`:
52
+
53
+ ```typescript
54
+ const result = await client.request<PaginatedResponse<Brand>>('GET', '/brands', {
55
+ params: {
56
+ page: 2,
57
+ limit: 25,
58
+ filter: { name_cont: 'nike' },
59
+ sort: '-created_at',
60
+ },
61
+ })
62
+ ```
63
+
64
+ The SDK transforms `filter`/`sort` into the Ransack-flavored query string the Admin API expects (`q[name_cont]=nike&q[s]=created_at%20desc`) automatically — same behavior as `client.products.list({ filter: ... })`.
65
+
66
+ ## See also
67
+
68
+ - The [Store SDK extending guide](../extending.md) covers the same pattern for storefront endpoints — the API is identical, only the base path (`/api/v3/store`) and auth header differ.
69
+ - The [API](../../tutorial/api.md) and [SDK](../../tutorial/sdk.md) tutorials walk through creating new Spree endpoints end-to-end (Rails controller, serializer, TypeScript types). Everything they show for the Store API applies equivalently for the Admin API.
70
+ - For building dashboard UI on top of your custom endpoint, see the [Dashboard](../../dashboard/overview.md) section — start with [Customization Quickstart](../../dashboard/customization/quickstart.md) for in-app changes or [Plugin Overview](../../dashboard/plugins/overview.md) for redistributable packages.
@@ -47,12 +47,12 @@ const product = await client.products.create({
47
47
  })
48
48
  ```
49
49
 
50
- Every method is fully typed — responses use `Admin`-prefixed types generated from the API serializers:
50
+ Every method is fully typed — responses use types generated from the API serializers:
51
51
 
52
52
  ```typescript
53
- import type { AdminOrder, PaginatedResponse } from '@spree/admin-sdk'
53
+ import type { Order, PaginatedResponse } from '@spree/admin-sdk'
54
54
 
55
- const page: PaginatedResponse<AdminOrder> = await client.orders.list()
55
+ const page: PaginatedResponse<Order> = await client.orders.list()
56
56
  ```
57
57
 
58
58
  > **NOTE:** Secret keys grant back-office access — never ship them in browser code. For browser-based admin apps, use [JWT cookie authentication](authentication.md#jwt--cookie-authentication-browser-apps) instead.
@@ -3,6 +3,8 @@ title: Admin Dashboard
3
3
  description: Scaffold the Spree admin UI for the Brands resource and add a rich text description editor, an Active Storage logo upload, and a custom table column.
4
4
  ---
5
5
 
6
+ > **NOTE:** This is a backend customization. You need to perform it in the `backend` directory if you used `create-spree-app` to set up your Spree application.
7
+
6
8
  Now that we've created the `Brand` model, let's create an Admin Dashboard interface so admins can manage brands — including editing the rich text description and uploading the logo.
7
9
 
8
10
  ## Step 1: Scaffold the Admin UI
@@ -4,6 +4,8 @@ sidebarTitle: API
4
4
  description: Expose your custom Brand model through the Spree Store and Admin REST APIs with serializers, controllers, routes, and the spree:api_resource generator.
5
5
  ---
6
6
 
7
+ > **NOTE:** This is a backend customization. You need to perform it in the `backend` directory if you used `create-spree-app` to set up your Spree application.
8
+
7
9
  In this tutorial, we'll expose our Brand model through Spree's v3 API — the customer-facing **Store API** that storefronts read from, and the back-office **Admin API** with full CRUD for apps and integrations. We'll also extend the existing Product serializer to include brand data.
8
10
 
9
11
  > **INFO:** This guide assumes you've completed the [Model](model.md), [Admin](admin.md), and [Extending Core Models](extending-models.md) tutorials.
@@ -3,6 +3,8 @@ title: Extending Core Models
3
3
  description: Tutorial — extend Spree's core models with a custom Brand resource, generate a migration, and associate it with Products via a decorator.
4
4
  ---
5
5
 
6
+ > **NOTE:** This is a backend customization. You need to perform it in the `backend` directory if you used `create-spree-app` to set up your Spree application.
7
+
6
8
  In this tutorial, we'll connect our custom Brand model with Spree's core Product model. This is a common pattern when building features that need to integrate with existing Spree functionality.
7
9
 
8
10
  > **INFO:** This guide assumes you've completed the [Model](model.md) and [Admin](admin.md) tutorials.
@@ -3,6 +3,8 @@ title: Model
3
3
  description: Create a custom Brand model in Spree with database columns, a rich text description, and an Active Storage logo upload using a single generator command.
4
4
  ---
5
5
 
6
+ > **NOTE:** This is a backend customization. You need to perform it in the `backend` directory if you used `create-spree-app` to set up your Spree application.
7
+
6
8
  In this step we'll create the `Spree::Brand` model with everything it needs: a `name` column, a rich text `description`, and an uploadable `logo`.
7
9
 
8
10
  ## Step 1: Generate the Model
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spree/docs",
3
- "version": "0.1.121",
3
+ "version": "0.1.123",
4
4
  "description": "Spree Commerce developer documentation for AI agents and local reference",
5
5
  "type": "module",
6
6
  "license": "CC-BY-4.0",