@spree/dashboard-core 0.13.1 → 1.0.0-beta.1

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 (125) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +52 -27
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +383 -127
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +19 -14
  36. package/src/components/table-toolbar.tsx +1357 -440
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +241 -16
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +9 -6
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/table-registry.ts +50 -0
  77. package/src/lib/type-labels.test.ts +68 -0
  78. package/src/lib/type-labels.ts +78 -0
  79. package/src/locales/ar.json +1889 -517
  80. package/src/locales/de.json +1884 -517
  81. package/src/locales/en.json +1886 -519
  82. package/src/locales/fr.json +1884 -517
  83. package/src/locales/pl.json +1913 -517
  84. package/src/locales/zh-CN.json +1883 -517
  85. package/src/pages/package-types.tsx +484 -0
  86. package/src/pages/settings-index.tsx +112 -0
  87. package/src/pages/stock-locations.tsx +674 -0
  88. package/src/plugin.ts +29 -0
  89. package/src/products/add-video-dialog.tsx +100 -0
  90. package/src/products/bulk-variants-dialog.tsx +301 -0
  91. package/src/products/carton-size.test.ts +51 -0
  92. package/src/products/carton-size.ts +20 -0
  93. package/src/products/currency-parts.ts +21 -0
  94. package/src/products/inventory-section.tsx +259 -0
  95. package/src/products/media-edit-sheet.tsx +336 -0
  96. package/src/products/normalize-customs.test.ts +39 -0
  97. package/src/products/normalize-customs.ts +27 -0
  98. package/src/products/normalize-money.test.ts +58 -0
  99. package/src/products/normalize-money.ts +73 -0
  100. package/src/products/normalize-quantity.test.ts +32 -0
  101. package/src/products/normalize-quantity.ts +31 -0
  102. package/src/products/product-bulk-price-editor.tsx +165 -0
  103. package/src/products/product-form-cards.tsx +1167 -0
  104. package/src/products/product-form-mapping.ts +212 -0
  105. package/src/products/product-schema.ts +318 -0
  106. package/src/products/product-types.ts +130 -0
  107. package/src/products/use-product-form-data.ts +273 -0
  108. package/src/products/variant-edit-sheet.tsx +725 -0
  109. package/src/products/variants-matrix.test.ts +154 -0
  110. package/src/products/variants-matrix.ts +244 -0
  111. package/src/products/variants-options-builder.tsx +571 -0
  112. package/src/products/variants-section.tsx +535 -0
  113. package/src/products/video-url.test.ts +71 -0
  114. package/src/products/video-url.ts +83 -0
  115. package/src/providers/auth-provider.tsx +70 -12
  116. package/src/providers/permission-provider.tsx +68 -28
  117. package/src/providers/sticky-header-provider.tsx +134 -0
  118. package/src/providers/store-provider.tsx +36 -3
  119. package/src/providers/tenant-provider.tsx +33 -0
  120. package/src/schemas/package-type.ts +95 -0
  121. package/src/schemas/stock-location.ts +126 -0
  122. package/src/tables/package-types.tsx +74 -0
  123. package/src/tables/stock-locations.tsx +124 -0
  124. package/src/vite/index.ts +40 -7
  125. package/src/components/top-bar.tsx +0 -209
@@ -1,15 +1,113 @@
1
- import type { Import, ImportCreateParams, ImportType } from '@spree/admin-sdk'
2
- import { useMutation } from '@tanstack/react-query'
3
- import { adminClient } from '../client'
1
+ import { toastManager } from '@spree/dashboard-ui'
2
+ import {
3
+ type QueryClient,
4
+ type QueryKey,
5
+ useMutation,
6
+ useQuery,
7
+ useQueryClient,
8
+ } from '@tanstack/react-query'
9
+ import { useEffect, useRef } from 'react'
10
+ import {
11
+ getApiClient,
12
+ type PanelImport,
13
+ type PanelImportCompleteMappingParams,
14
+ type PanelImportCreateParams,
15
+ type PanelImportDelimiter,
16
+ } from '../api-client'
4
17
  import { downloadFromApi } from '../lib/download'
5
- import { useStore } from '../providers/store-provider'
18
+ import { useResourceKey, useResourceKeyBuilder } from '../lib/query-keys'
19
+ import { useTenantId } from '../providers/tenant-provider'
6
20
  import { useAuth } from './use-auth'
7
21
 
22
+ /** Statuses the wizard polls through — row creation and processing. */
23
+ const ACTIVE_STATUSES = new Set(['completed_mapping', 'processing'])
24
+
25
+ const IMPORT_POLL_INTERVAL_MS = 2000
26
+ const ROWS_POLL_INTERVAL_MS = 5000
27
+
28
+ // Caches any import may have written to, plus `imports` itself (history table
29
+ // status column) and `translations` (product-translation CSV updates the
30
+ // coverage grid). Deliberately not per-type — imports are rare, so a few
31
+ // extra refetches beat maintaining a type → resource map (product rows alone
32
+ // fan out to option types/values and categories created on the fly).
33
+ //
34
+ // These are the operator dashboard's key names; a panel that names its lists
35
+ // differently adds its own through the client's `invalidateKeys`.
36
+ // Everything an import type can write. `price-lists` itself stays out: a
37
+ // list's own record does not change when its prices do, and refetching it
38
+ // would reset the form under a merchant's unsaved edits.
39
+ const IMPORT_TOUCHED_RESOURCES = [
40
+ 'products',
41
+ 'option-types',
42
+ 'categories',
43
+ 'customers',
44
+ 'imports',
45
+ 'translations',
46
+ 'prices',
47
+ 'catalogs',
48
+ ]
49
+
50
+ /** Whether the import's pipeline is still running (the poll's continue predicate). */
51
+ export function isImportActive(status: string | undefined): boolean {
52
+ return !!status && ACTIVE_STATUSES.has(status)
53
+ }
54
+
55
+ /**
56
+ * Marks every cache an import can have written as stale. The pipeline writes
57
+ * records server-side, outside any tracked mutation, so nothing else ever
58
+ * does; called when an import finishes, and when its wizard is closed while
59
+ * it is still running (after which nothing is left to see it finish).
60
+ */
61
+ export function invalidateImportTouchedResources(
62
+ queryClient: QueryClient,
63
+ buildKey: (resource: string) => QueryKey,
64
+ ) {
65
+ const extraKeys = getApiClient().imports?.invalidateKeys
66
+ for (const resource of new Set([...IMPORT_TOUCHED_RESOURCES, ...(extraKeys ?? [])])) {
67
+ queryClient.invalidateQueries({ queryKey: buildKey(resource) })
68
+ }
69
+ }
70
+
71
+ /**
72
+ * The panel's import resource.
73
+ *
74
+ * Throws rather than returning undefined: every hook here is reached from UI
75
+ * that only renders when the panel registered one, so a miss is a boot
76
+ * misconfiguration and should say so where it happens.
77
+ */
78
+ function imports() {
79
+ const resource = getApiClient().imports
80
+ if (!resource) {
81
+ throw new Error(
82
+ '@spree/dashboard-core: this panel registered no `imports` client. ' +
83
+ 'Add one in setApiClient(...) before rendering import UI.',
84
+ )
85
+ }
86
+
87
+ return resource
88
+ }
89
+
90
+ /** Create-time parameters beyond the ones every import shares. */
91
+ export type ImportCreateExtras = Omit<
92
+ PanelImportCreateParams,
93
+ 'type' | 'attachment' | 'preferred_delimiter' | 'results_url'
94
+ >
95
+
8
96
  export interface CreateImportInput {
9
- type: ImportType
97
+ type: string
10
98
  /** Signed blob id of the already direct-uploaded CSV (see `FileUploadField`). */
11
99
  signedId: string
12
- preferredDelimiter?: ImportCreateParams['preferred_delimiter']
100
+ preferredDelimiter?: PanelImportDelimiter
101
+ /**
102
+ * Whatever else the import type needs at create — the price list a
103
+ * `price_list_prices` import merges into, say. Passed through untouched.
104
+ */
105
+ params?: ImportCreateExtras
106
+ /**
107
+ * Where the import-done email should link back to. Defaults to this panel's
108
+ * own imports view under the current tenant.
109
+ */
110
+ resultsPath?: string
13
111
  }
14
112
 
15
113
  /**
@@ -17,32 +115,157 @@ export interface CreateImportInput {
17
115
  * `mapping` state and carries the mapping payload.
18
116
  */
19
117
  export function useCreateImport() {
20
- const { storeId } = useStore()
118
+ const tenantId = useTenantId()
21
119
 
22
120
  return useMutation({
23
- mutationFn: ({ type, signedId, preferredDelimiter }: CreateImportInput): Promise<Import> =>
24
- adminClient.imports.create({
121
+ mutationFn: ({
122
+ type,
123
+ signedId,
124
+ preferredDelimiter,
125
+ params,
126
+ resultsPath,
127
+ }: CreateImportInput): Promise<PanelImport> =>
128
+ imports().create({
129
+ ...params,
25
130
  type,
26
131
  attachment: signedId,
27
132
  preferred_delimiter: preferredDelimiter,
28
133
  // The import-done email deep-links back to the wizard (`?import=<id>`
29
134
  // appended server-side). Only honored when this origin is on the
30
135
  // store's allowed-origins list.
31
- results_url: `${window.location.origin}/${storeId}/settings/imports`,
136
+ results_url: `${window.location.origin}${resultsPath ?? `/${tenantId}/settings/imports`}`,
32
137
  }),
33
138
  })
34
139
  }
35
140
 
141
+ /**
142
+ * Single import, polled every 2s while the pipeline is running. `mapping`
143
+ * (user-driven) and terminal statuses don't poll — a mapping-state refetch
144
+ * would re-read the attached CSV server-side on every tick.
145
+ *
146
+ * A finished import (completed, or failed — earlier rows may still have been
147
+ * written) invalidates every cache imports can touch: the pipeline writes
148
+ * records server-side, outside any tracked mutation, so nothing else ever
149
+ * marks those lists stale and "View products" would keep serving the
150
+ * pre-import cache.
151
+ */
152
+ export function useImport(id: string) {
153
+ const queryClient = useQueryClient()
154
+ const buildKey = useResourceKeyBuilder()
155
+
156
+ const query = useQuery({
157
+ queryKey: useResourceKey('imports', id),
158
+ queryFn: () => imports().get(id),
159
+ enabled: !!id,
160
+ refetchInterval: (query) =>
161
+ isImportActive(query.state.data?.status) ? IMPORT_POLL_INTERVAL_MS : false,
162
+ })
163
+
164
+ // Fires when the poll lands on a finished status — including again after
165
+ // each retry pass (`finished` flips false → true anew). Reopening an
166
+ // already-finished import refires it; harmless, invalidation is idempotent.
167
+ const status = query.data?.status
168
+ const finished = status === 'completed' || status === 'failed'
169
+ useEffect(() => {
170
+ if (finished) invalidateImportTouchedResources(queryClient, buildKey)
171
+ }, [finished, queryClient, buildKey])
172
+
173
+ // Unmounted while the import is still running — the wizard closed, or the
174
+ // merchant navigated away — nothing is left to see it finish, so the caches
175
+ // it will have written are marked stale now. An import the poll has not
176
+ // answered for yet counts as running: nothing says it is finished.
177
+ const unfinished = useRef(true)
178
+ unfinished.current = !finished
179
+ useEffect(
180
+ () => () => {
181
+ if (unfinished.current) invalidateImportTouchedResources(queryClient, buildKey)
182
+ },
183
+ [queryClient, buildKey],
184
+ )
185
+
186
+ return query
187
+ }
188
+
189
+ /** Rows of an import (the failure report), optionally polled while processing. */
190
+ export function useImportRows(
191
+ importId: string,
192
+ params: Record<string, unknown>,
193
+ options?: { poll?: boolean },
194
+ ) {
195
+ return useQuery({
196
+ queryKey: useResourceKey('imports', importId, 'rows', params),
197
+ queryFn: () => imports().rows.list(importId, params),
198
+ enabled: !!importId,
199
+ refetchInterval: options?.poll ? ROWS_POLL_INTERVAL_MS : false,
200
+ placeholderData: (previous) => previous,
201
+ })
202
+ }
203
+
204
+ export function useCompleteMapping(id: string) {
205
+ const queryClient = useQueryClient()
206
+ const buildKey = useResourceKeyBuilder()
207
+
208
+ return useMutation({
209
+ mutationFn: (params?: PanelImportCompleteMappingParams) =>
210
+ imports().completeMapping(id, params),
211
+ onSuccess: (imp) => {
212
+ queryClient.setQueryData<PanelImport>(buildKey('imports', id), imp)
213
+ // Keep the history table's status column in sync.
214
+ queryClient.invalidateQueries({ queryKey: buildKey('imports') })
215
+ },
216
+ })
217
+ }
218
+
219
+ export function useRetryFailedRows(id: string) {
220
+ const queryClient = useQueryClient()
221
+ const buildKey = useResourceKeyBuilder()
222
+
223
+ return useMutation({
224
+ mutationFn: () => imports().retryFailedRows(id),
225
+ onSuccess: (imp) => {
226
+ queryClient.setQueryData<PanelImport>(buildKey('imports', id), imp)
227
+ // `refetchType: 'none'` refreshes the history list on its next mount
228
+ // without refetching the active wizard detail query: the retry response
229
+ // already carried the fresh `processing` status we just cached, and an
230
+ // immediate refetch would race the (fast) background retry job — often
231
+ // returning `completed` before the "Retrying failed rows" state is ever
232
+ // rendered. Marking stale keeps the poll as the single source of the
233
+ // completion transition.
234
+ queryClient.invalidateQueries({ queryKey: buildKey('imports'), refetchType: 'none' })
235
+ },
236
+ onError: (err) => {
237
+ toastManager.add({ type: 'error', title: err instanceof Error ? err.message : String(err) })
238
+ },
239
+ })
240
+ }
241
+
242
+ export function useDeleteImport() {
243
+ const queryClient = useQueryClient()
244
+ const buildKey = useResourceKeyBuilder()
245
+
246
+ return useMutation({
247
+ mutationFn: (id: string) => imports().delete(id),
248
+ onSuccess: (_data, id) => {
249
+ queryClient.removeQueries({ queryKey: buildKey('imports', id) })
250
+ queryClient.invalidateQueries({ queryKey: buildKey('imports') })
251
+ },
252
+ onError: (err) => {
253
+ toastManager.add({ type: 'error', title: err instanceof Error ? err.message : String(err) })
254
+ },
255
+ })
256
+ }
257
+
36
258
  /** Downloads the CSV template for an import type. */
37
259
  export function useDownloadImportTemplate() {
38
260
  const { token } = useAuth()
39
261
 
40
262
  return useMutation({
41
- mutationFn: (type: ImportType) =>
263
+ mutationFn: (type: string) =>
42
264
  downloadFromApi(
43
265
  token,
44
- `/api/v3/admin/imports/template?type=${encodeURIComponent(type)}`,
266
+ imports().templateUrl(type),
45
267
  'import_template.csv',
268
+ getApiClient().downloadHeaders?.() ?? {},
46
269
  ),
47
270
  })
48
271
  }
@@ -60,11 +283,12 @@ export function useDownloadImportExample() {
60
283
  const { token } = useAuth()
61
284
 
62
285
  return useMutation({
63
- mutationFn: (type: ImportType) =>
286
+ mutationFn: (type: string) =>
64
287
  downloadFromApi(
65
288
  token,
66
- `/api/v3/admin/imports/example?type=${encodeURIComponent(type)}`,
289
+ imports().exampleUrl(type),
67
290
  `${type}.csv`,
291
+ getApiClient().downloadHeaders?.() ?? {},
68
292
  ),
69
293
  })
70
294
  }
@@ -74,11 +298,12 @@ export function useDownloadImportOriginal() {
74
298
  const { token } = useAuth()
75
299
 
76
300
  return useMutation({
77
- mutationFn: (imp: Import) =>
301
+ mutationFn: (imp: PanelImport) =>
78
302
  downloadFromApi(
79
303
  token,
80
- imp.original_file_url ?? `/api/v3/admin/imports/${imp.id}/download`,
304
+ imp.original_file_url ?? imports().downloadUrl(imp.id),
81
305
  imp.original_filename ?? 'import.csv',
306
+ getApiClient().downloadHeaders?.() ?? {},
82
307
  ),
83
308
  })
84
309
  }
@@ -0,0 +1,132 @@
1
+ import type { PaginationMeta } from '@spree/dashboard-ui'
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
3
+ import i18n from 'i18next'
4
+ import {
5
+ getApiClient,
6
+ type PanelPackageType,
7
+ type PanelPackageTypeCreateParams,
8
+ type PanelPackageTypeParams,
9
+ type PanelPackageTypeWrites,
10
+ } from '../api-client'
11
+ import { useResourceKey, useResourceKeyBuilder } from '../lib/query-keys'
12
+ import { useResourceMutation } from './use-resource-mutation'
13
+
14
+ /**
15
+ * The registered panel's package-type resource.
16
+ *
17
+ * Throws rather than answering undefined: every hook here is only reachable
18
+ * from the packaging page, and a panel that routes to it without registering
19
+ * the resource is misconfigured — failing at the call site says so far more
20
+ * clearly than a handful of "cannot read property of undefined" errors.
21
+ */
22
+ function resource() {
23
+ const packageTypes = getApiClient().packageTypes
24
+ if (!packageTypes) {
25
+ throw new Error(
26
+ '@spree/dashboard-core: this panel registered no `packageTypes` resource, so the ' +
27
+ 'package-types page cannot load. Add one in setApiClient(), or drop the route.',
28
+ )
29
+ }
30
+ return packageTypes
31
+ }
32
+
33
+ /**
34
+ * The same resource narrowed to the writing shape.
35
+ *
36
+ * The narrowing is checked once here rather than at each call site: the page
37
+ * only renders its sheets when `canWritePackageTypes()` is true, and the
38
+ * panel's registration is what makes that so.
39
+ */
40
+ function writableResource(): PanelPackageTypeWrites {
41
+ const packageTypes = resource()
42
+ if (!('create' in packageTypes)) {
43
+ throw new Error(
44
+ '@spree/dashboard-core: this panel registered `packageTypes` reads only, so packaging ' +
45
+ 'cannot be written. Register get/create/update, or drop the settings route.',
46
+ )
47
+ }
48
+ return packageTypes
49
+ }
50
+
51
+ export function usePackageType(id: string | undefined) {
52
+ return useQuery({
53
+ queryKey: useResourceKey('package-types', id ?? 'noop'),
54
+ queryFn: () => writableResource().get(id as string),
55
+ enabled: !!id,
56
+ })
57
+ }
58
+
59
+ export function useCreatePackageType() {
60
+ return useResourceMutation<PanelPackageType, Error, PanelPackageTypeCreateParams>({
61
+ mutationFn: (params) => writableResource().create(params),
62
+ invalidate: [['package-types'], ['panel-form-carton-package-types']],
63
+ successMessage: i18n.t('admin.package_types.messages.added'),
64
+ errorMessage: i18n.t('admin.errors.failed_to_create'),
65
+ })
66
+ }
67
+
68
+ export function useUpdatePackageType(id: string) {
69
+ return useResourceMutation<PanelPackageType, Error, PanelPackageTypeParams>({
70
+ mutationFn: (params) => writableResource().update(id, params),
71
+ invalidate: [['package-types'], ['package-types', id], ['panel-form-carton-package-types']],
72
+ successMessage: i18n.t('admin.package_types.messages.updated'),
73
+ errorMessage: i18n.t('admin.errors.failed_to_update'),
74
+ })
75
+ }
76
+
77
+ export function useDeletePackageType() {
78
+ const queryClient = useQueryClient()
79
+ const buildKey = useResourceKeyBuilder()
80
+
81
+ return useResourceMutation<void, Error, string>({
82
+ mutationFn: (id) => {
83
+ const remove = writableResource().delete
84
+ if (!remove) throw new Error('This panel cannot delete package types.')
85
+ return remove(id)
86
+ },
87
+ invalidate: [['package-types'], ['panel-form-carton-package-types']],
88
+ successMessage: i18n.t('admin.package_types.messages.deleted'),
89
+ errorMessage: i18n.t('admin.errors.failed_to_delete'),
90
+ onSuccess: (_data, id) => {
91
+ queryClient.removeQueries({ queryKey: buildKey('package-types', id) })
92
+ },
93
+ })
94
+ }
95
+
96
+ /**
97
+ * Whether this panel's API can write packaging at all. A panel registering
98
+ * only `list` gets the variant editor's carton picker and no settings page,
99
+ * so the page hides everything that would write.
100
+ */
101
+ export function canWritePackageTypes(): boolean {
102
+ const packageTypes = getApiClient().packageTypes
103
+ if (packageTypes === undefined) return false
104
+
105
+ // All three, not just `create`: the page renders Edit off this answer, and
106
+ // a plain JavaScript host can register a partial object the write shape's
107
+ // type would have refused.
108
+ return ['get', 'create', 'update'].every((method) => method in packageTypes)
109
+ }
110
+
111
+ /** Whether this panel's API offers deletion, which not every one does. */
112
+ export function canDeletePackageTypes(): boolean {
113
+ const packageTypes = getApiClient().packageTypes
114
+ return packageTypes !== undefined && 'delete' in packageTypes
115
+ }
116
+
117
+ /**
118
+ * One page of packaging, for `ResourceTable`'s own query. Not a hook — the
119
+ * table calls it with the search params it has already parsed.
120
+ */
121
+ export function listPackageTypes(params: Record<string, unknown>) {
122
+ // `owner: 'mine'` — the settings page shows what this panel's principal
123
+ // owns, never another owner's rows. On the operator's panel every row is
124
+ // theirs and the param is ignored; on a seller's it hides the
125
+ // marketplace's shared packaging, which the seller reads through the
126
+ // variant editor's carton picker instead. A mixed list read as "packaging
127
+ // is configured" while the seller had recorded none.
128
+ return resource().list({ ...params, owner: 'mine' }) as Promise<{
129
+ data: PanelPackageType[]
130
+ meta: PaginationMeta
131
+ }>
132
+ }
@@ -1,21 +1,21 @@
1
1
  import { SpreeError } from '@spree/admin-sdk'
2
+ import { toastManager } from '@spree/dashboard-ui'
2
3
  import {
3
4
  type QueryKey,
4
5
  type UseMutationOptions,
5
6
  useMutation,
6
7
  useQueryClient,
7
8
  } from '@tanstack/react-query'
8
- import { toast } from 'sonner'
9
9
  import { i18n } from '../lib/i18n'
10
10
  import { withStoreScope } from '../lib/query-keys'
11
- import { useStore } from '../providers/store-provider'
11
+ import { useTenantId } from '../providers/tenant-provider'
12
12
 
13
13
  interface UseResourceMutationOptions<TData, TError, TVariables>
14
14
  extends Omit<UseMutationOptions<TData, TError, TVariables>, 'onSuccess' | 'onError'> {
15
15
  /**
16
16
  * Query keys to invalidate after success. Pass logical keys without
17
- * storeId (e.g. `[['channels'], ['channels', id]]`) — the hook injects the
18
- * current storeId at position 1 automatically so invalidation matches the
17
+ * tenant id (e.g. `[['channels'], ['channels', id]]`) — the hook injects the
18
+ * current tenant id at position 1 automatically so invalidation matches the
19
19
  * store-scoped keys used by `ResourceTable` and other queries.
20
20
  *
21
21
  * Mutations that change setup-task state (see `Spree.store_setup_tasks` on
@@ -23,6 +23,18 @@ interface UseResourceMutationOptions<TData, TError, TVariables>
23
23
  * Started checklist and the nav progress badge stay live.
24
24
  */
25
25
  invalidate?: QueryKey[]
26
+ /**
27
+ * Key segments that must NOT be refreshed, even when `invalidate` matches
28
+ * them by prefix. `invalidate: [['categories', id]]` also sweeps nested
29
+ * lists like `['categories', id, 'products']`; passing `['products']` here
30
+ * holds those back.
31
+ *
32
+ * Use it where a parent's own save runs alongside writes to one of its
33
+ * nested collections: refreshing the collection from the parent's mutation
34
+ * lands mid-save, painting the pre-save rows for a frame before the real
35
+ * state arrives.
36
+ */
37
+ doNotInvalidate?: string[]
26
38
  /** Toast on success. Pass `false` to disable. Default `'Saved'`. */
27
39
  successMessage?: string | false
28
40
  /**
@@ -34,6 +46,12 @@ interface UseResourceMutationOptions<TData, TError, TVariables>
34
46
  * auth, gateway).
35
47
  */
36
48
  errorMessage?: string | false
49
+ /**
50
+ * Toast a field-level 422 too, joining its messages. Set this on a page
51
+ * with no form to render them inline — otherwise the rejection is silent
52
+ * and the merchant sees a button re-enable with no explanation.
53
+ */
54
+ showValidationErrors?: boolean
37
55
  /** Forwarded onSuccess callback. Runs after invalidation + toast. */
38
56
  onSuccess?: UseMutationOptions<TData, TError, TVariables>['onSuccess']
39
57
  /** Forwarded onError callback. Runs after error toast. */
@@ -59,11 +77,13 @@ export function useResourceMutation<TData = unknown, TError = Error, TVariables
59
77
  options: UseResourceMutationOptions<TData, TError, TVariables>,
60
78
  ) {
61
79
  const queryClient = useQueryClient()
62
- const { storeId } = useStore()
80
+ const tenantId = useTenantId()
63
81
  const {
64
82
  invalidate,
83
+ doNotInvalidate,
65
84
  successMessage = i18n.t('admin.messages.saved'),
66
85
  errorMessage = i18n.t('admin.errors.generic'),
86
+ showValidationErrors = false,
67
87
  onSuccess,
68
88
  onError,
69
89
  ...rest
@@ -74,26 +94,67 @@ export function useResourceMutation<TData = unknown, TError = Error, TVariables
74
94
  onSuccess: (data, variables, onMutateResult, ctx) => {
75
95
  if (invalidate) {
76
96
  for (const key of invalidate) {
77
- queryClient.invalidateQueries({ queryKey: withStoreScope(key, storeId) })
97
+ const scoped = withStoreScope(key, tenantId)
98
+ queryClient.invalidateQueries({
99
+ queryKey: scoped,
100
+ predicate: doNotInvalidate?.length
101
+ ? (query) => !query.queryKey.some((part) => doNotInvalidate.includes(part as string))
102
+ : undefined,
103
+ })
78
104
  }
79
105
  }
80
106
  if (successMessage !== false) {
81
- toast.success(successMessage)
107
+ toastManager.add({ type: 'success', title: successMessage })
82
108
  }
83
109
  return onSuccess?.(data, variables, onMutateResult, ctx)
84
110
  },
85
111
  onError: (error, variables, onMutateResult, ctx) => {
86
- if (errorMessage !== false && !isValidationError(error)) {
87
- toast.error(errorMessage)
112
+ if (errorMessage !== false && showValidationErrors && isValidationError(error)) {
113
+ toastManager.add({ type: 'error', title: fieldMessages(error) ?? errorMessage })
114
+ } else if (errorMessage !== false && !isValidationError(error)) {
115
+ // A refusal with no field to hang off — a workflow saying the source
116
+ // shelf cannot cover this transfer — says something the caller's
117
+ // generic message does not, so it speaks for itself.
118
+ toastManager.add({ type: 'error', title: refusalMessage(error) ?? errorMessage })
88
119
  }
89
120
  return onError?.(error, variables, onMutateResult, ctx)
90
121
  },
91
122
  })
92
123
  }
93
124
 
94
- // 422 with `details` means the model rejected the payload (e.g., "Code can't
95
- // be blank"). The form renders these next to the offending input via
96
- // `mapSpreeErrorsToForm`, so toasting the same thing would just be noise.
125
+ // 422 *with* `details` means the model rejected the payload field by field
126
+ // (e.g. "Code can't be blank"). The form renders those next to the offending
127
+ // input via `mapSpreeErrorsToForm`, so toasting them again would be noise.
128
+ //
129
+ // A 422 without `details` is a different animal: a workflow declining the
130
+ // whole operation ("Only a transfer in transit can be received"). Nothing
131
+ // renders it inline, so suppressing it leaves the merchant staring at a page
132
+ // that silently did nothing.
97
133
  function isValidationError(error: unknown): boolean {
98
- return error instanceof SpreeError && error.status === 422
134
+ return error instanceof SpreeError && error.status === 422 && hasFieldDetails(error)
135
+ }
136
+
137
+ function hasFieldDetails(error: SpreeError): boolean {
138
+ return !!error.details && Object.keys(error.details).length > 0
139
+ }
140
+
141
+ /** Every field message the server sent, as one line. */
142
+ function fieldMessages(error: unknown): string | undefined {
143
+ if (!(error instanceof SpreeError) || !error.details) return undefined
144
+
145
+ const lines = Object.values(error.details).flatMap((entry) =>
146
+ (entry as Array<string | { message?: string }>).map((item) =>
147
+ typeof item === 'string' ? item : item?.message,
148
+ ),
149
+ )
150
+ const joined = lines.filter(Boolean).join('. ')
151
+
152
+ return joined || undefined
153
+ }
154
+
155
+ /** The server's own wording for a refusal that names no field. */
156
+ function refusalMessage(error: unknown): string | undefined {
157
+ if (!(error instanceof SpreeError) || hasFieldDetails(error)) return undefined
158
+
159
+ return error.message || undefined
99
160
  }