@spree/dashboard-core 0.13.0 → 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 (126) 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 +97 -36
  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 +425 -131
  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 +63 -22
  36. package/src/components/table-toolbar.tsx +1373 -442
  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 +261 -14
  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 +21 -0
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/storefront.ts +19 -0
  77. package/src/lib/table-registry.ts +56 -0
  78. package/src/lib/type-labels.test.ts +68 -0
  79. package/src/lib/type-labels.ts +78 -0
  80. package/src/locales/ar.json +1889 -516
  81. package/src/locales/de.json +1883 -515
  82. package/src/locales/en.json +1886 -516
  83. package/src/locales/fr.json +1884 -516
  84. package/src/locales/pl.json +1913 -516
  85. package/src/locales/zh-CN.json +1883 -516
  86. package/src/pages/package-types.tsx +484 -0
  87. package/src/pages/settings-index.tsx +112 -0
  88. package/src/pages/stock-locations.tsx +674 -0
  89. package/src/plugin.ts +29 -0
  90. package/src/products/add-video-dialog.tsx +100 -0
  91. package/src/products/bulk-variants-dialog.tsx +301 -0
  92. package/src/products/carton-size.test.ts +51 -0
  93. package/src/products/carton-size.ts +20 -0
  94. package/src/products/currency-parts.ts +21 -0
  95. package/src/products/inventory-section.tsx +259 -0
  96. package/src/products/media-edit-sheet.tsx +336 -0
  97. package/src/products/normalize-customs.test.ts +39 -0
  98. package/src/products/normalize-customs.ts +27 -0
  99. package/src/products/normalize-money.test.ts +58 -0
  100. package/src/products/normalize-money.ts +73 -0
  101. package/src/products/normalize-quantity.test.ts +32 -0
  102. package/src/products/normalize-quantity.ts +31 -0
  103. package/src/products/product-bulk-price-editor.tsx +165 -0
  104. package/src/products/product-form-cards.tsx +1167 -0
  105. package/src/products/product-form-mapping.ts +212 -0
  106. package/src/products/product-schema.ts +318 -0
  107. package/src/products/product-types.ts +130 -0
  108. package/src/products/use-product-form-data.ts +273 -0
  109. package/src/products/variant-edit-sheet.tsx +725 -0
  110. package/src/products/variants-matrix.test.ts +154 -0
  111. package/src/products/variants-matrix.ts +244 -0
  112. package/src/products/variants-options-builder.tsx +571 -0
  113. package/src/products/variants-section.tsx +535 -0
  114. package/src/products/video-url.test.ts +71 -0
  115. package/src/products/video-url.ts +83 -0
  116. package/src/providers/auth-provider.tsx +70 -12
  117. package/src/providers/permission-provider.tsx +68 -28
  118. package/src/providers/sticky-header-provider.tsx +134 -0
  119. package/src/providers/store-provider.tsx +36 -3
  120. package/src/providers/tenant-provider.tsx +33 -0
  121. package/src/schemas/package-type.ts +95 -0
  122. package/src/schemas/stock-location.ts +126 -0
  123. package/src/tables/package-types.tsx +74 -0
  124. package/src/tables/stock-locations.tsx +124 -0
  125. package/src/vite/index.ts +40 -7
  126. package/src/components/top-bar.tsx +0 -210
@@ -0,0 +1,643 @@
1
+ /**
2
+ * The client registry.
3
+ *
4
+ * `@spree/dashboard-core` is the framework behind more than one panel: the
5
+ * marketplace operator's dashboard talks to the Admin API, a seller's panel
6
+ * talks to the Seller API. Those are different surfaces with different
7
+ * credentials — a seller JWT and `X-Spree-Seller-Id` rather than a secret key
8
+ * and a store header — so the framework cannot own either client. Each host
9
+ * builds its own and registers it here at boot.
10
+ *
11
+ * Only the surface both panels share is typed. Anything admin-only
12
+ * (`customFieldDefinitions`, `imports`, store switching) stays on the admin
13
+ * client, imported directly by the code that needs it — typing it here would
14
+ * promise the seller panel methods its API does not have.
15
+ */
16
+
17
+ import type { PermissionRule } from '@spree/admin-sdk'
18
+
19
+ /** What the permission provider needs, whichever panel asked. */
20
+ export interface PanelPermissions {
21
+ /**
22
+ * CanCanCan rules. The seller panel returns none — its capability is the
23
+ * key list alone — so an empty array is a valid answer, not a failure.
24
+ */
25
+ rules: PermissionRule[]
26
+ keys: string[]
27
+ }
28
+
29
+ /** What every panel's client can do, whichever API it talks to. */
30
+ export interface PanelApiClient {
31
+ auth: {
32
+ login(params: { email: string; password: string }): Promise<PanelSession>
33
+ refresh(): Promise<PanelSession>
34
+ logout(): Promise<void>
35
+ /**
36
+ * Optional sign-in flows, because not every panel offers all of them.
37
+ * Both panels accept invitations and reset passwords — each against its
38
+ * own API, so the session that comes back carries the right audience and
39
+ * the emailed link opens the right panel. First-run setup remains the
40
+ * marketplace's own, so a seller's client leaves it undefined.
41
+ */
42
+ acceptInvitation?(id: string, token: string, params: unknown): Promise<PanelSession>
43
+ resetPassword?(token: string, params: unknown): Promise<PanelSession>
44
+ completeSetup?(params: unknown): Promise<PanelSession>
45
+ }
46
+ setToken(token: string): void
47
+ onUnauthorized(handler: () => Promise<boolean>): void
48
+ /**
49
+ * Forgets which tenant the session was acting as — the store on the admin
50
+ * panel, the seller on a seller's. Left set, it rides into the next
51
+ * principal's first requests and 403s them against a tenant they may hold
52
+ * no role on.
53
+ */
54
+ clearTenant?(): void
55
+ /**
56
+ * Reads the signed-in principal's capability. Panel-specific because the
57
+ * two APIs answer different shapes: the admin `/me` returns CanCanCan rules
58
+ * and keys, the seller `/me` returns keys scoped to the selected seller.
59
+ */
60
+ fetchPermissions(): Promise<PanelPermissions>
61
+ /**
62
+ * Countries (with their states) for the shared address form.
63
+ *
64
+ * Registered rather than imported, for the same reason as everything else
65
+ * here: reaching for `adminClient` would make the one address form work
66
+ * only in the operator's panel, and a seller filling in a billing address
67
+ * would get an empty country list.
68
+ */
69
+ listCountries?(): Promise<{ data: PanelCountry[] }>
70
+ /**
71
+ * Stock locations, for the shared management page.
72
+ *
73
+ * Both panels manage the same records against their own branch — the
74
+ * operator every location in the store, a seller only their own, scoped
75
+ * server-side. `delete` is optional because a seller's API does not offer
76
+ * it: a location holds stock levels and is named on historical
77
+ * fulfillments, so they retire one by deactivating it.
78
+ */
79
+ /**
80
+ * Exchanges blob metadata for a presigned upload URL.
81
+ *
82
+ * Registered rather than imported for the same reason as everything else
83
+ * here: the shared upload field would otherwise work only in the
84
+ * operator's panel, and a seller uploading an onboarding document would
85
+ * get a 401 from an API they hold no key for.
86
+ */
87
+ createDirectUpload?(params: {
88
+ blob: { filename: string; byte_size: number; checksum: string; content_type: string }
89
+ }): Promise<{
90
+ direct_upload: { url: string; headers: Record<string, string> }
91
+ signed_id: string
92
+ }>
93
+ stockLocations?: {
94
+ list(params?: Record<string, unknown>): Promise<{ data: PanelStockLocation[]; meta?: unknown }>
95
+ get(id: string): Promise<PanelStockLocation>
96
+ create(params: PanelStockLocationCreateParams): Promise<PanelStockLocation>
97
+ update(id: string, params: PanelStockLocationParams): Promise<PanelStockLocation>
98
+ delete?(id: string): Promise<void>
99
+ }
100
+ /**
101
+ * The reference data the shared product form reads: the option types a
102
+ * variant is built from, and the categories, collections and product types
103
+ * a product is filed under.
104
+ *
105
+ * Registered for the same reason as everything above — the form is one set
106
+ * of cards serving both panels, and reaching for `adminClient` inside them
107
+ * would leave a seller with empty pickers. Each is optional: a panel that
108
+ * offers no such picker simply does not register it, and the card hides.
109
+ */
110
+ optionTypes?: {
111
+ list(params?: Record<string, unknown>): Promise<{ data: PanelOptionType[] }>
112
+ /**
113
+ * Naming a new option (Size, Colour) while building a variant matrix.
114
+ * Operator-only: a seller names options inline on the variant instead,
115
+ * so their client leaves these out and the builder hides.
116
+ *
117
+ * `name` and `label` are named rather than left to a loose record — they
118
+ * are what the API requires, and a contract that did not say so would
119
+ * push the check to a cast at the registration site.
120
+ */
121
+ create?(
122
+ params: { name: string; label: string } & Record<string, unknown>,
123
+ ): Promise<PanelOptionType>
124
+ update?(
125
+ id: string,
126
+ params: { option_values?: Array<{ name: string; label: string }> } & Record<string, unknown>,
127
+ ): Promise<PanelOptionType>
128
+ }
129
+ categories?: {
130
+ list(params?: Record<string, unknown>): Promise<{ data: PanelNamedRecord[] }>
131
+ }
132
+ collections?: {
133
+ list(params?: Record<string, unknown>): Promise<{ data: PanelCollection[] }>
134
+ }
135
+ productTypes?: {
136
+ list(params?: Record<string, unknown>): Promise<{ data: PanelProductType[] }>
137
+ get(id: string): Promise<PanelProductType>
138
+ }
139
+ /**
140
+ * Tax category is marketplace configuration a seller does not write, so
141
+ * their client registers none and the card that reads it hides.
142
+ *
143
+ * Delivery profiles are different: the marketplace defines them, but a
144
+ * seller assigns one to their own product — it decides how their goods can
145
+ * be shipped at all — so both panels register this one
146
+ * (docs/plans/6.0-multi-vendor-marketplace.md, Decision 13).
147
+ */
148
+ taxCategories?: {
149
+ list(params?: Record<string, unknown>): Promise<{ data: PanelNamedRecord[] }>
150
+ }
151
+ deliveryProfiles?: {
152
+ list(params?: Record<string, unknown>): Promise<{ data: PanelDeliveryProfile[] }>
153
+ }
154
+ /**
155
+ * The packaging this panel can see: the boxes parcels ship in, the cartons
156
+ * products are packed into, the pallets a wholesale order leaves on.
157
+ *
158
+ * `list` alone is enough for the variant editor's carton picker, which is
159
+ * why the write methods are optional — a panel that only needs the picker
160
+ * registers `list` and the packaging settings page is simply not routed.
161
+ */
162
+ packageTypes?: PanelPackageTypeReads | PanelPackageTypeWrites
163
+ /**
164
+ * Headers a file download must carry beyond the bearer token.
165
+ *
166
+ * A download is a bare `fetch`, not an SDK call, so it bypasses whatever
167
+ * the client normally attaches — which on the seller panel is
168
+ * `X-Spree-Seller-Id`, and without it every Seller API request is refused
169
+ * before the action runs. The admin panel needs none, so it registers none.
170
+ */
171
+ downloadHeaders?(): Record<string, string>
172
+ /**
173
+ * Queuing a CSV export and reading it back.
174
+ *
175
+ * Registered rather than imported for the same reason as everything else
176
+ * here: the export dialog is one component serving both panels, and
177
+ * reaching for `adminClient` inside it would make a seller's export request
178
+ * go out against an API they hold no key for.
179
+ *
180
+ * Which datasets may be exported is the API's business, not this
181
+ * contract's — each panel offers its own list, and the server refuses the
182
+ * rest. A panel that offers no export simply registers none, and the button
183
+ * hides.
184
+ */
185
+ exports?: {
186
+ create(params: PanelExportCreateParams): Promise<PanelExport>
187
+ get(id: string): Promise<PanelExport>
188
+ }
189
+ /** Removing a file already on a product. */
190
+ deleteProductMedia?(productId: string, mediaId: string): Promise<void>
191
+ /**
192
+ * The store-wide media library, for placing a file already uploaded onto
193
+ * another product. Operator-only: a seller sees their own product's files,
194
+ * never the marketplace's library, so their client registers none and the
195
+ * "add from library" affordance hides.
196
+ */
197
+ mediaLibrary?: {
198
+ list(params?: Record<string, unknown>): Promise<{ data: PanelMediaRecord[] }>
199
+ }
200
+ /**
201
+ * CSV imports, for whichever catalog this panel manages.
202
+ *
203
+ * Registered rather than imported for the same reason as everything else
204
+ * here: the wizard is one component serving both panels, and reaching for
205
+ * `adminClient` inside it would leave a seller uploading against an API
206
+ * they hold no key for.
207
+ *
208
+ * Structural, not either SDK's type — the two branches serve the same
209
+ * payload from parallel serializers, and the wizard reads only what both
210
+ * carry. `templateUrl` and `exampleUrl` are functions rather than fixed
211
+ * strings because each panel's endpoints live under its own branch, and the
212
+ * download helpers take a URL rather than a resource method (they stream
213
+ * with the caller's JWT).
214
+ */
215
+ imports?: {
216
+ list(params?: Record<string, unknown>): Promise<{ data: PanelImport[]; meta?: PanelPageMeta }>
217
+ get(id: string): Promise<PanelImport>
218
+ create(params: PanelImportCreateParams): Promise<PanelImport>
219
+ completeMapping(id: string, params?: PanelImportCompleteMappingParams): Promise<PanelImport>
220
+ retryFailedRows(id: string): Promise<PanelImport>
221
+ delete(id: string): Promise<void>
222
+ rows: {
223
+ list(
224
+ importId: string,
225
+ params?: Record<string, unknown>,
226
+ ): Promise<{ data: PanelImportRow[]; meta?: PanelPageMeta }>
227
+ }
228
+ /** Which datasets this panel may import — the wizard offers no others. */
229
+ types: readonly string[]
230
+ /**
231
+ * Query-key roots a finished import should mark stale, on top of the
232
+ * defaults core invalidates.
233
+ *
234
+ * The pipeline writes records server-side, outside any tracked mutation,
235
+ * so nothing else ever marks those lists stale — and each panel names its
236
+ * lists differently (the operator's products table is keyed `products`,
237
+ * a seller's `seller-products`). A panel that does not name its own keys
238
+ * gets a list still serving its pre-import cache, which reads as an
239
+ * import that silently did nothing.
240
+ */
241
+ invalidateKeys?: readonly string[]
242
+ /**
243
+ * Paths to this panel's import endpoints, relative to the API origin.
244
+ *
245
+ * Root-relative on purpose: `downloadFromApi` resolves them against
246
+ * `VITE_SPREE_API_URL` and sends the JWT only when the result is that
247
+ * origin, so a panel pointed at a different API host must set that
248
+ * variable too (both starters feed the client and the download path from
249
+ * it). An absolute URL to another host is safe but pointless — the token
250
+ * is withheld and the request will not authenticate.
251
+ */
252
+ templateUrl(type: string): string
253
+ exampleUrl(type: string): string
254
+ downloadUrl(id: string): string
255
+ }
256
+ /**
257
+ * The store's tag vocabulary. Operator-only: tags are tenanted to the store
258
+ * (`acts_as_taggable_tenant :store_id`), so a seller typing a new one would
259
+ * be writing into the marketplace's own namespace.
260
+ */
261
+ tags?: {
262
+ list(params?: Record<string, unknown>): Promise<{ data: Array<{ name: string }> }>
263
+ }
264
+ /**
265
+ * Currency-to-locale pairs, which is what tells a money field whether
266
+ * `34,56` is thirty-four and a half or three thousand. The operator's
267
+ * markets answer it; a panel that registers none falls back to the UI
268
+ * language, the same answer the operator's hook gives for an unmatched
269
+ * currency.
270
+ */
271
+ markets?: {
272
+ list(params?: Record<string, unknown>): Promise<{ data: PanelMarket[] }>
273
+ }
274
+ }
275
+
276
+ export interface PanelDeliveryProfile extends PanelNamedRecord {
277
+ default?: boolean
278
+ }
279
+
280
+ export interface PanelPackageType extends PanelNamedRecord {
281
+ kind?: string
282
+ /**
283
+ * Read alongside the name so a picker can tell two cartons apart. Decimals
284
+ * arrive as strings, as every measurement on the wire does.
285
+ */
286
+ length?: string | null
287
+ width?: string | null
288
+ height?: string | null
289
+ dimensions_unit?: string | null
290
+ /** The empty package's own weight, added to every quote. */
291
+ weight?: string | null
292
+ max_weight?: string | null
293
+ weight_unit?: string | null
294
+ /** Cubic meters, derived; null until every side is measured. */
295
+ volume?: string | null
296
+ /** The box this owner's parcels are quoted with — one per owner. */
297
+ default?: boolean
298
+ /**
299
+ * False on a row this panel may read but not change: the seller panel lists
300
+ * the marketplace's packaging so a seller knows what they can pack into,
301
+ * and every write against it is refused. Absent on the operator's own
302
+ * serializer, where every row is theirs, so the page treats undefined as
303
+ * editable.
304
+ */
305
+ editable?: boolean
306
+ /** Whose packaging this is; null is the marketplace's own. */
307
+ seller_id?: string | null
308
+ seller_name?: string | null
309
+ created_at?: string
310
+ updated_at?: string
311
+ }
312
+
313
+ /**
314
+ * Measurements written back for a package type. Every field optional: the
315
+ * form sends the whole shape on create, and a partial one when a single
316
+ * measurement changes.
317
+ */
318
+ export interface PanelPackageTypeParams {
319
+ name?: string
320
+ /**
321
+ * Narrow rather than `string`, because both SDKs validate the vocabulary at
322
+ * the type level and a widened contract would not assign to either.
323
+ */
324
+ kind?: PanelPackageTypeKind
325
+ length?: number | null
326
+ width?: number | null
327
+ height?: number | null
328
+ dimensions_unit?: PanelPackageDimensionUnit | null
329
+ weight?: number | null
330
+ max_weight?: number | null
331
+ weight_unit?: PanelPackageWeightUnit | null
332
+ default?: boolean
333
+ /**
334
+ * Not nullable: both SDKs take a merge, so clearing a key is sending the
335
+ * object without it rather than sending null.
336
+ */
337
+ metadata?: Record<string, unknown>
338
+ }
339
+
340
+ /**
341
+ * Enough for the variant editor's carton picker: a panel that only needs the
342
+ * picker registers this and never routes to the packaging settings page.
343
+ */
344
+ export interface PanelPackageTypeReads {
345
+ list(params?: Record<string, unknown>): Promise<{ data: PanelPackageType[]; meta?: unknown }>
346
+ }
347
+
348
+ /**
349
+ * What the packaging settings page needs. Required rather than optional, so a
350
+ * panel that routes to the page while registering reads only fails to
351
+ * compile instead of rendering a page whose every write throws.
352
+ */
353
+ export interface PanelPackageTypeWrites extends PanelPackageTypeReads {
354
+ get(id: string): Promise<PanelPackageType>
355
+ create(params: PanelPackageTypeCreateParams): Promise<PanelPackageType>
356
+ update(id: string, params: PanelPackageTypeParams): Promise<PanelPackageType>
357
+ delete?(id: string): Promise<void>
358
+ }
359
+
360
+ export type PanelPackageTypeKind = 'box' | 'envelope' | 'carton' | 'pallet' | 'container'
361
+ export type PanelPackageDimensionUnit = 'mm' | 'cm' | 'in' | 'ft'
362
+ export type PanelPackageWeightUnit = 'g' | 'kg' | 'lb' | 'oz'
363
+
364
+ /**
365
+ * A new row must say what it is and what to call it — packaging without
366
+ * either cannot exist, and neither SDK makes them optional.
367
+ */
368
+ export type PanelPackageTypeCreateParams = PanelPackageTypeParams & {
369
+ name: string
370
+ kind: PanelPackageTypeKind
371
+ }
372
+
373
+ /**
374
+ * One CSV import, as the shared wizard reads it: the mapping payload while
375
+ * `mapping`, the poll counters once processing starts.
376
+ */
377
+ export interface PanelImport {
378
+ id: string
379
+ /** Human-facing identifier shown in the wizard header (`IM1001`). */
380
+ number: string
381
+ type: string | null
382
+ status: string
383
+ rows_count: number
384
+ completed_rows_count: number
385
+ failed_rows_count: number
386
+ processing_errors?: string | null
387
+ preferred_delimiter?: string
388
+ schema_fields: Array<{ name: string; label: string; required: boolean }>
389
+ csv_headers: string[]
390
+ sample_row: Record<string, string | null>
391
+ mappings: Array<{ schema_field: string; file_column: string | null; required: boolean }>
392
+ original_filename?: string | null
393
+ original_byte_size?: number | null
394
+ original_file_url?: string | null
395
+ /** The list a `price_list_prices` import merges into; null elsewhere. */
396
+ price_list_id?: string | null
397
+ created_at?: string
398
+ updated_at?: string
399
+ }
400
+
401
+ /**
402
+ * Pagination meta, as both APIs answer it. Structural for the same reason the
403
+ * records are: the two branches serve one shape and the shared UI reads only
404
+ * what both carry.
405
+ */
406
+ export interface PanelPageMeta {
407
+ count: number
408
+ page: number
409
+ pages: number
410
+ from?: number
411
+ to?: number
412
+ }
413
+
414
+ /** One row of an import — the failure report's unit. */
415
+ export interface PanelImportRow {
416
+ id: string
417
+ row_number: number
418
+ status: string
419
+ validation_errors?: string | null
420
+ data?: Record<string, string | null>
421
+ }
422
+
423
+ export interface PanelImportCreateParams {
424
+ type: string
425
+ /** Signed blob id of the already direct-uploaded CSV. */
426
+ attachment: string
427
+ /** CSV column separator — the four both APIs accept. */
428
+ preferred_delimiter?: PanelImportDelimiter
429
+ /** The price list a `price_list_prices` import merges into. */
430
+ price_list_id?: string
431
+ results_url?: string
432
+ }
433
+
434
+ /** The column separators the import upload form offers. */
435
+ export type PanelImportDelimiter = ',' | ';' | '|' | '\t'
436
+
437
+ export interface PanelImportCompleteMappingParams {
438
+ mappings?: Array<{ schema_field: string; file_column: string | null }>
439
+ }
440
+
441
+ /** A market, as the money fields read it. */
442
+ export interface PanelMarket {
443
+ id: string
444
+ currency?: string | null
445
+ default_locale?: string | null
446
+ }
447
+
448
+ /**
449
+ * What either panel sends to queue an export.
450
+ *
451
+ * `type` stays a plain string: the operator's registry and a seller's
452
+ * allowlist are different sets, and each SDK narrows it to its own.
453
+ */
454
+ export interface PanelExportCreateParams {
455
+ type: string
456
+ search_params?: Record<string, unknown>
457
+ record_selection?: 'filtered' | 'all'
458
+ results_url?: string
459
+ }
460
+
461
+ /**
462
+ * An export as the dialog polls it. Structural rather than either SDK's
463
+ * type — both serializers carry exactly this, and what only one of them adds
464
+ * is not the dialog's business.
465
+ */
466
+ export interface PanelExport {
467
+ id: string
468
+ done: boolean
469
+ download_url?: string | null
470
+ filename?: string | null
471
+ }
472
+
473
+ /** A library file, as the picker lists it. */
474
+ export interface PanelMediaRecord {
475
+ id: string
476
+ alt?: string | null
477
+ small_url?: string | null
478
+ original_url?: string | null
479
+ }
480
+
481
+ /** Anything the form lists by name in a picker. */
482
+ export interface PanelNamedRecord {
483
+ id: string
484
+ name: string
485
+ }
486
+
487
+ /**
488
+ * A rule-based collection fills itself, so the form offers only manual ones
489
+ * to pick from.
490
+ */
491
+ export interface PanelCollection extends PanelNamedRecord {
492
+ automatic?: boolean
493
+ }
494
+
495
+ /**
496
+ * An option type as the variants builder reads it. Structural, like
497
+ * PanelStockLocation: both serializers carry the name and its values, and
498
+ * what only one of them adds is not the form's business.
499
+ */
500
+ export interface PanelOptionType {
501
+ id: string
502
+ name: string
503
+ label: string
504
+ kind?: string
505
+ position?: number
506
+ option_values?: Array<{
507
+ id: string
508
+ name: string
509
+ label: string
510
+ position?: number
511
+ color_code?: string | null
512
+ }>
513
+ }
514
+
515
+ export interface PanelProductType {
516
+ id: string
517
+ name: string
518
+ option_type_ids?: string[]
519
+ /**
520
+ * The option types picking this type will add, already named. A panel that
521
+ * manages no option-type vocabulary (a seller's) cannot resolve
522
+ * `option_type_ids` against a list it never loads, so its API sends the
523
+ * labels instead and the form reads whichever it was given.
524
+ */
525
+ option_type_labels?: string[]
526
+ category_ids?: string[]
527
+ custom_field_definitions?: unknown[]
528
+ }
529
+
530
+ /**
531
+ * A stock location as the shared page reads it.
532
+ *
533
+ * Structural rather than either SDK's type: the two serializers overlap on
534
+ * everything this page shows, and the fields only one of them carries
535
+ * (`admin_name`, `propagate_all_variants`) are optional here so the page can
536
+ * hide what a panel does not send.
537
+ */
538
+ export interface PanelStockLocation {
539
+ id: string
540
+ name: string
541
+ admin_name?: string | null
542
+ kind: string
543
+ active: boolean
544
+ default: boolean
545
+ propagate_all_variants?: boolean
546
+ backorderable_default?: boolean
547
+ address1?: string | null
548
+ address2?: string | null
549
+ city?: string | null
550
+ zipcode?: string | null
551
+ phone?: string | null
552
+ company?: string | null
553
+ country_code?: string | null
554
+ state_code?: string | null
555
+ state_name?: string | null
556
+ /** Rendered read-only in the table; either panel's serializer derives it. */
557
+ state_text?: string | null
558
+ pickup_enabled?: boolean
559
+ /** Whether this location accepts goods coming back. */
560
+ returns_enabled?: boolean
561
+ /** Open on read — a serializer answers whatever is stored. */
562
+ pickup_stock_policy?: string
563
+ pickup_ready_in_minutes?: number | null
564
+ pickup_instructions?: string | null
565
+ }
566
+
567
+ /**
568
+ * What either panel accepts when updating one — every field optional, since
569
+ * a caller may be writing a single toggle.
570
+ */
571
+ export type PanelStockLocationParams = Partial<
572
+ Omit<PanelStockLocation, 'id' | 'pickup_stock_policy'>
573
+ > & {
574
+ /**
575
+ * Narrowed on write where the read type is open: `kind` stays a free string
576
+ * because plugins register their own, but the pickup policy is a fixed pair
577
+ * the server validates, and both SDKs type it that way.
578
+ */
579
+ pickup_stock_policy?: 'local' | 'any'
580
+ }
581
+
582
+ /**
583
+ * Creating one additionally requires a name, matching both APIs: a location
584
+ * without one cannot exist, and neither SDK makes it optional.
585
+ */
586
+ export type PanelStockLocationCreateParams = PanelStockLocationParams & { name: string }
587
+
588
+ /**
589
+ * A country as the address form needs it. Structural, not the Admin SDK's
590
+ * `Country`: both panels' country endpoints answer this shape, and typing
591
+ * the wider one here would bind core to a package a seller's panel does not
592
+ * install.
593
+ */
594
+ export interface PanelCountry {
595
+ iso: string
596
+ iso3: string
597
+ name: string
598
+ states_required?: boolean
599
+ zipcode_required?: boolean
600
+ states?: Array<{ abbr: string; name: string }>
601
+ }
602
+
603
+ /** What a sign-in returns, whichever panel asked. */
604
+ export interface PanelSession {
605
+ token: string
606
+ // Panel-specific: an AdminUser here, the same class through the seller API.
607
+ // Typed loosely so core does not depend on either package's user type.
608
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
609
+ user: any
610
+ }
611
+
612
+ let registered: PanelApiClient | null = null
613
+
614
+ /**
615
+ * Registers the client for this panel. Call once at boot, before rendering —
616
+ * providers read it on mount.
617
+ */
618
+ export function setApiClient(client: PanelApiClient): void {
619
+ registered = client
620
+ }
621
+
622
+ /**
623
+ * The registered client.
624
+ *
625
+ * Throws rather than returning null: a panel that renders without registering
626
+ * one is misconfigured, and every call would otherwise fail somewhere further
627
+ * away with a less useful message.
628
+ */
629
+ export function getApiClient(): PanelApiClient {
630
+ if (!registered) {
631
+ throw new Error(
632
+ '@spree/dashboard-core: no API client registered. Call setApiClient(client) at boot, ' +
633
+ 'before rendering the app.',
634
+ )
635
+ }
636
+
637
+ return registered
638
+ }
639
+
640
+ /** Whether a client has been registered. For tests and conditional boot paths. */
641
+ export function hasApiClient(): boolean {
642
+ return registered !== null
643
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { resolveAddressCountryCode } from './address-form-dialog'
3
+
4
+ describe('resolveAddressCountryCode', () => {
5
+ it('keeps the country stored on an existing address', () => {
6
+ expect(resolveAddressCountryCode({ id: 'addr_1', country_code: 'DE' }, 'US')).toBe('DE')
7
+ })
8
+
9
+ it('uses the store default when the address has no country', () => {
10
+ expect(resolveAddressCountryCode({ first_name: 'Ada' }, 'US')).toBe('US')
11
+ expect(resolveAddressCountryCode(null, 'PL')).toBe('PL')
12
+ expect(resolveAddressCountryCode(undefined, 'GB')).toBe('GB')
13
+ })
14
+
15
+ it('treats a blank stored country as missing', () => {
16
+ expect(resolveAddressCountryCode({ country_code: '' }, 'FR')).toBe('FR')
17
+ expect(resolveAddressCountryCode({ country_code: null }, 'FR')).toBe('FR')
18
+ })
19
+
20
+ it('stays empty when neither the address nor the store has a country', () => {
21
+ expect(resolveAddressCountryCode(null, null)).toBe('')
22
+ expect(resolveAddressCountryCode(undefined, undefined)).toBe('')
23
+ expect(resolveAddressCountryCode({ country_code: '' }, '')).toBe('')
24
+ })
25
+ })