@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.
- package/README.md +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +52 -27
- package/src/components/import-wizard-dialog.tsx +710 -0
- package/src/components/locale-select.tsx +70 -57
- package/src/components/media-picker-sheet.tsx +307 -0
- package/src/components/mobile-top-bar.tsx +75 -0
- package/src/components/nav-main.tsx +197 -27
- package/src/components/page-header.tsx +203 -61
- package/src/components/page-tabs.tsx +4 -1
- package/src/components/preferences-form.tsx +98 -4
- package/src/components/resource-combobox.tsx +52 -4
- package/src/components/resource-picker-sheet.tsx +209 -73
- package/src/components/resource-table.tsx +383 -127
- package/src/components/route-announcer.tsx +64 -0
- package/src/components/section-heading.tsx +95 -0
- package/src/components/settings-sidebar.tsx +290 -60
- package/src/components/sheet-top-bar.tsx +44 -0
- package/src/components/sidebar-search.tsx +83 -0
- package/src/components/sidebar-user.tsx +213 -0
- package/src/components/skip-link.tsx +36 -0
- package/src/components/store-date-picker.tsx +6 -2
- package/src/components/store-switcher.tsx +19 -14
- package/src/components/table-toolbar.tsx +1357 -440
- package/src/components/tag-combobox.tsx +12 -4
- package/src/components/top-bar-breadcrumbs.tsx +235 -0
- package/src/components/wizard-close-guard.test.ts +54 -0
- package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
- package/src/hooks/use-command-palette.tsx +13 -0
- package/src/hooks/use-countries.test.ts +56 -0
- package/src/hooks/use-countries.ts +51 -5
- package/src/hooks/use-direct-upload.ts +16 -3
- package/src/hooks/use-export.ts +51 -16
- package/src/hooks/use-import.ts +241 -16
- package/src/hooks/use-package-types.ts +132 -0
- package/src/hooks/use-resource-mutation.ts +74 -13
- package/src/hooks/use-stock-locations.ts +131 -0
- package/src/hooks/use-switch-admin-locale.ts +2 -2
- package/src/hooks/use-visual-viewport-offset.ts +61 -0
- package/src/index.ts +45 -1
- package/src/lib/cancel-order-form.ts +27 -0
- package/src/lib/create-action-registry.ts +278 -0
- package/src/lib/date-presets.test.ts +86 -0
- package/src/lib/date-presets.ts +98 -0
- package/src/lib/delivery-form.ts +15 -0
- package/src/lib/docs.ts +32 -0
- package/src/lib/download.ts +13 -1
- package/src/lib/filters-to-ransack.test.ts +113 -0
- package/src/lib/filters-to-ransack.ts +22 -1
- package/src/lib/form-errors.ts +122 -10
- package/src/lib/form-mappers.ts +76 -0
- package/src/lib/fulfillment-items.test.ts +213 -0
- package/src/lib/fulfillment-items.ts +171 -0
- package/src/lib/i18n.ts +19 -11
- package/src/lib/import-types.ts +25 -0
- package/src/lib/label-upload-form.ts +22 -0
- package/src/lib/nav-registry.ts +71 -3
- package/src/lib/permissions.ts +46 -1
- package/src/lib/progress.ts +18 -0
- package/src/lib/query-keys.ts +13 -13
- package/src/lib/sample-csv.ts +9 -6
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/table-registry.ts +50 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -517
- package/src/locales/de.json +1884 -517
- package/src/locales/en.json +1886 -519
- package/src/locales/fr.json +1884 -517
- package/src/locales/pl.json +1913 -517
- package/src/locales/zh-CN.json +1883 -517
- package/src/pages/package-types.tsx +484 -0
- package/src/pages/settings-index.tsx +112 -0
- package/src/pages/stock-locations.tsx +674 -0
- package/src/plugin.ts +29 -0
- package/src/products/add-video-dialog.tsx +100 -0
- package/src/products/bulk-variants-dialog.tsx +301 -0
- package/src/products/carton-size.test.ts +51 -0
- package/src/products/carton-size.ts +20 -0
- package/src/products/currency-parts.ts +21 -0
- package/src/products/inventory-section.tsx +259 -0
- package/src/products/media-edit-sheet.tsx +336 -0
- package/src/products/normalize-customs.test.ts +39 -0
- package/src/products/normalize-customs.ts +27 -0
- package/src/products/normalize-money.test.ts +58 -0
- package/src/products/normalize-money.ts +73 -0
- package/src/products/normalize-quantity.test.ts +32 -0
- package/src/products/normalize-quantity.ts +31 -0
- package/src/products/product-bulk-price-editor.tsx +165 -0
- package/src/products/product-form-cards.tsx +1167 -0
- package/src/products/product-form-mapping.ts +212 -0
- package/src/products/product-schema.ts +318 -0
- package/src/products/product-types.ts +130 -0
- package/src/products/use-product-form-data.ts +273 -0
- package/src/products/variant-edit-sheet.tsx +725 -0
- package/src/products/variants-matrix.test.ts +154 -0
- package/src/products/variants-matrix.ts +244 -0
- package/src/products/variants-options-builder.tsx +571 -0
- package/src/products/variants-section.tsx +535 -0
- package/src/products/video-url.test.ts +71 -0
- package/src/products/video-url.ts +83 -0
- package/src/providers/auth-provider.tsx +70 -12
- package/src/providers/permission-provider.tsx +68 -28
- package/src/providers/sticky-header-provider.tsx +134 -0
- package/src/providers/store-provider.tsx +36 -3
- package/src/providers/tenant-provider.tsx +33 -0
- package/src/schemas/package-type.ts +95 -0
- package/src/schemas/stock-location.ts +126 -0
- package/src/tables/package-types.tsx +74 -0
- package/src/tables/stock-locations.tsx +124 -0
- package/src/vite/index.ts +40 -7
- package/src/components/top-bar.tsx +0 -209
|
@@ -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
|
+
})
|