@spree/dashboard-core 1.0.0-beta.8 → 1.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/vite/discover.js +6 -1
- package/package.json +3 -3
- package/src/components/address-form-dialog.tsx +23 -3
- package/src/components/can.tsx +2 -2
- package/src/components/preferences-form.tsx +31 -23
- package/src/components/resource-combobox.tsx +4 -1
- package/src/components/settings-sidebar.tsx +2 -4
- package/src/components/store-switcher.tsx +1 -1
- package/src/hooks/use-global-search.ts +1 -12
- package/src/lib/filters-to-ransack.test.ts +1 -1
- package/src/lib/form-errors.ts +19 -4
- package/src/lib/formatters.ts +4 -0
- package/src/lib/permissions.ts +75 -76
- package/src/lib/table-registry.ts +5 -7
- package/src/locales/ar.json +7 -1
- package/src/locales/de.json +7 -1
- package/src/locales/en.json +7 -1
- package/src/locales/es.json +7 -1
- package/src/locales/fr.json +7 -1
- package/src/locales/pl.json +7 -1
- package/src/locales/zh-CN.json +7 -1
- package/src/pages/settings-index.tsx +1 -1
- package/src/products/product-form-cards.tsx +97 -5
- package/src/products/product-form-mapping.test.ts +60 -1
- package/src/products/product-form-mapping.ts +21 -0
- package/src/providers/permission-provider.tsx +7 -1
- package/src/vite/discover.ts +21 -0
package/dist/vite/discover.js
CHANGED
|
@@ -63,6 +63,10 @@ function discoverDashboardPlugins({ root, onWarn }) {
|
|
|
63
63
|
}
|
|
64
64
|
return discovered;
|
|
65
65
|
}
|
|
66
|
+
function resolveShellRoutesDir(root, packageName) {
|
|
67
|
+
const require2 = createRequire(path.join(root, "package.json"));
|
|
68
|
+
return path.join(path.dirname(fs.realpathSync(require2.resolve(packageName))), "routes");
|
|
69
|
+
}
|
|
66
70
|
function resolveManifest(pkg, require2) {
|
|
67
71
|
try {
|
|
68
72
|
return require2.resolve(`${pkg}/package.json`);
|
|
@@ -85,5 +89,6 @@ function resolveManifest(pkg, require2) {
|
|
|
85
89
|
}
|
|
86
90
|
export {
|
|
87
91
|
discoverDashboardPluginManifests,
|
|
88
|
-
discoverDashboardPlugins
|
|
92
|
+
discoverDashboardPlugins,
|
|
93
|
+
resolveShellRoutesDir
|
|
89
94
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spree/dashboard-core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "1.0.0-
|
|
4
|
+
"version": "1.0.0-rc.2",
|
|
5
5
|
"description": "Spree Dashboard framework — registries (table, nav, slot, settings-nav), providers (auth, permission, store), admin SDK client, generic infra hooks, and the defineDashboardPlugin extension API. Developer Preview.",
|
|
6
6
|
"author": "Vendo Connect Inc.",
|
|
7
7
|
"homepage": "https://spreecommerce.org",
|
|
@@ -81,8 +81,8 @@
|
|
|
81
81
|
"typescript": "~6.0.3",
|
|
82
82
|
"vite": "^8.0.16",
|
|
83
83
|
"vitest": "^4.1.5",
|
|
84
|
-
"@spree/admin-sdk": "^1.0.0-
|
|
85
|
-
"@spree/dashboard-ui": "^1.0.0-
|
|
84
|
+
"@spree/admin-sdk": "^1.0.0-rc.1",
|
|
85
|
+
"@spree/dashboard-ui": "^1.0.0-rc.2"
|
|
86
86
|
},
|
|
87
87
|
"scripts": {
|
|
88
88
|
"lint": "biome check src",
|
|
@@ -100,6 +100,11 @@ const businessSchema = z.object({
|
|
|
100
100
|
|
|
101
101
|
type AddressFormValues = z.infer<typeof personalSchema>
|
|
102
102
|
|
|
103
|
+
/** Address rows validate on `state`; every address form binds `state_code`. */
|
|
104
|
+
const ADDRESS_SERVER_FIELD_ALIASES = { state: 'state_code' } as const satisfies Partial<
|
|
105
|
+
Record<string, keyof AddressFormValues>
|
|
106
|
+
>
|
|
107
|
+
|
|
103
108
|
/**
|
|
104
109
|
* Just the fields this dialog reads.
|
|
105
110
|
*
|
|
@@ -202,6 +207,9 @@ export function AddressFormDialog({
|
|
|
202
207
|
})
|
|
203
208
|
const { errors } = form.formState
|
|
204
209
|
|
|
210
|
+
const countryCode = form.watch('country_code')
|
|
211
|
+
const { states, statesRequired } = useCountryStates(countryCode)
|
|
212
|
+
|
|
205
213
|
// The parent keys the dialog on the address id so a fresh instance mounts
|
|
206
214
|
// for each open, but `address` can also stream in async (loaded after the
|
|
207
215
|
// sheet mounts). Reset only when the *record identity* changes — otherwise a
|
|
@@ -225,14 +233,20 @@ export function AddressFormDialog({
|
|
|
225
233
|
form.setValue('country_code', storeDefaultCountryCode)
|
|
226
234
|
}, [address?.country_code, storeDefaultCountryCode, form])
|
|
227
235
|
|
|
228
|
-
const countryCode = form.watch('country_code')
|
|
229
|
-
const { states } = useCountryStates(countryCode)
|
|
230
236
|
// Show the dropdown whenever the country enumerates subdivisions, not only
|
|
231
237
|
// when it strictly requires one — so a country like Pakistan still lets the
|
|
232
238
|
// user pick a province instead of typing a name that may not match.
|
|
233
239
|
const useStateCombobox = states.length > 0
|
|
234
240
|
|
|
235
241
|
async function onSubmit(values: AddressFormValues) {
|
|
242
|
+
if (statesRequired && !values.state_code.trim()) {
|
|
243
|
+
form.setError('state_code', {
|
|
244
|
+
type: 'manual',
|
|
245
|
+
message: requiredMessage('state_code')(),
|
|
246
|
+
})
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
236
250
|
try {
|
|
237
251
|
await onSave({
|
|
238
252
|
first_name: values.first_name,
|
|
@@ -255,7 +269,8 @@ export function AddressFormDialog({
|
|
|
255
269
|
// Surface server-side 422 validation errors on the matching fields so
|
|
256
270
|
// the dialog reflects whatever the API rejected (e.g. "phone is too
|
|
257
271
|
// short"). Non-validation errors bubble to the parent's toast.
|
|
258
|
-
if (!mapSpreeErrorsToForm(err, form.setError))
|
|
272
|
+
if (!mapSpreeErrorsToForm(err, form.setError, { fieldAliases: ADDRESS_SERVER_FIELD_ALIASES }))
|
|
273
|
+
throw err
|
|
259
274
|
}
|
|
260
275
|
}
|
|
261
276
|
|
|
@@ -284,6 +299,11 @@ export function AddressFormDialog({
|
|
|
284
299
|
className="flex flex-col flex-1 overflow-hidden"
|
|
285
300
|
>
|
|
286
301
|
<div className="flex-1 overflow-y-auto p-4">
|
|
302
|
+
{errors.root?.message && (
|
|
303
|
+
<p className="mb-4 text-sm text-destructive" role="alert">
|
|
304
|
+
{errors.root.message}
|
|
305
|
+
</p>
|
|
306
|
+
)}
|
|
287
307
|
<FieldGroup>
|
|
288
308
|
{showLabel && (
|
|
289
309
|
<Field>
|
package/src/components/can.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { usePermissions } from '../providers/permission-provider'
|
|
|
5
5
|
interface CanProps {
|
|
6
6
|
/** Action name (e.g. "update", "destroy", "manage") */
|
|
7
7
|
I: ActionName
|
|
8
|
-
/** Subject
|
|
8
|
+
/** Subject short name (e.g. "product") — see `Subject` */
|
|
9
9
|
a: SubjectName
|
|
10
10
|
children: ReactNode
|
|
11
11
|
fallback?: ReactNode
|
|
@@ -16,7 +16,7 @@ interface CanProps {
|
|
|
16
16
|
* can perform `I` on `a`, otherwise renders `fallback` (or nothing).
|
|
17
17
|
*
|
|
18
18
|
* Example:
|
|
19
|
-
* <Can I="destroy" a=
|
|
19
|
+
* <Can I="destroy" a={Subject.Product}>
|
|
20
20
|
* <Button onClick={handleDelete}>Delete</Button>
|
|
21
21
|
* </Can>
|
|
22
22
|
*
|
|
@@ -170,8 +170,8 @@ export function PreferenceField({
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
// `tiers` preference (Spree::Calculator::TieredPercent /
|
|
173
|
-
// TieredFlatRate) — a
|
|
174
|
-
//
|
|
173
|
+
// TieredFlatRate) — a list of `{ threshold, value }` objects that the
|
|
174
|
+
// default array input would render as `[object Object]`. Render a row
|
|
175
175
|
// editor so the merchant can add tier breakpoints directly.
|
|
176
176
|
if (field.key === 'tiers') {
|
|
177
177
|
return <TiersEditor value={value} onChange={onChange} />
|
|
@@ -356,6 +356,11 @@ function isMaxKey(key: string): boolean {
|
|
|
356
356
|
return /(?:^|_)max(?:imum)?(?:_|$)/.test(key)
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
+
interface TierValue {
|
|
360
|
+
threshold: string
|
|
361
|
+
value: string
|
|
362
|
+
}
|
|
363
|
+
|
|
359
364
|
interface TierRowState {
|
|
360
365
|
/** Stable across edits — used for React keys so input focus survives reorder. */
|
|
361
366
|
uid: string
|
|
@@ -365,21 +370,21 @@ interface TierRowState {
|
|
|
365
370
|
|
|
366
371
|
/**
|
|
367
372
|
* Editor for `Spree::Calculator::TieredPercent` / `TieredFlatRate`
|
|
368
|
-
* preferences. The underlying shape is `
|
|
369
|
-
* at or above `threshold` get `value` (% or $ depending
|
|
370
|
-
* calculator).
|
|
373
|
+
* preferences. The underlying shape is a list of `{ threshold, value }`
|
|
374
|
+
* objects — orders at or above `threshold` get `value` (% or $ depending
|
|
375
|
+
* on the calculator).
|
|
371
376
|
*
|
|
372
377
|
* Owns its row list locally so the user can add an empty row and fill
|
|
373
|
-
* it in afterwards — projecting to the
|
|
378
|
+
* it in afterwards — projecting to the list on every change would drop
|
|
374
379
|
* empty-threshold rows the moment they're added. Empty rows are
|
|
375
|
-
* filtered when projecting to the parent's
|
|
380
|
+
* filtered when projecting to the parent's value.
|
|
376
381
|
*/
|
|
377
382
|
function TiersEditor({
|
|
378
383
|
value,
|
|
379
384
|
onChange,
|
|
380
385
|
}: {
|
|
381
386
|
value: unknown
|
|
382
|
-
onChange: (next:
|
|
387
|
+
onChange: (next: TierValue[]) => void
|
|
383
388
|
}) {
|
|
384
389
|
const { t } = useTranslation()
|
|
385
390
|
const idPrefix = useId()
|
|
@@ -392,14 +397,12 @@ function TiersEditor({
|
|
|
392
397
|
|
|
393
398
|
function commit(next: TierRowState[]) {
|
|
394
399
|
setRows(next)
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
402
|
-
onChange(out)
|
|
400
|
+
// Sent as the strings typed, so the server stores the exact decimal.
|
|
401
|
+
onChange(
|
|
402
|
+
next
|
|
403
|
+
.filter((row) => row.threshold.trim())
|
|
404
|
+
.map((row) => ({ threshold: row.threshold.trim(), value: row.value.trim() || '0' })),
|
|
405
|
+
)
|
|
403
406
|
}
|
|
404
407
|
|
|
405
408
|
function updateRow(uid: string, patch: Partial<Pick<TierRowState, 'threshold' | 'value'>>) {
|
|
@@ -471,6 +474,7 @@ function TierRow({
|
|
|
471
474
|
min={0}
|
|
472
475
|
value={row.threshold}
|
|
473
476
|
placeholder="100"
|
|
477
|
+
aria-label={t('admin.components.preferences_form.tiers.header_threshold')}
|
|
474
478
|
onChange={(e) => onChange({ threshold: e.target.value })}
|
|
475
479
|
/>
|
|
476
480
|
<Input
|
|
@@ -479,6 +483,7 @@ function TierRow({
|
|
|
479
483
|
min={0}
|
|
480
484
|
value={row.value}
|
|
481
485
|
placeholder="10"
|
|
486
|
+
aria-label={t('admin.components.preferences_form.tiers.header_value')}
|
|
482
487
|
onChange={(e) => onChange({ value: e.target.value })}
|
|
483
488
|
/>
|
|
484
489
|
<Button
|
|
@@ -495,17 +500,20 @@ function TierRow({
|
|
|
495
500
|
}
|
|
496
501
|
|
|
497
502
|
/**
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
503
|
+
* Converts the server's list of `{ threshold, value }` objects into
|
|
504
|
+
* editor rows. A calculator saved before Spree 6.0 that the upgrade has
|
|
505
|
+
* not converted yet still sends a hash keyed by threshold, so that shape
|
|
506
|
+
* is read too.
|
|
502
507
|
*/
|
|
503
508
|
function parseTiers(value: unknown, idPrefix: string): TierRowState[] {
|
|
504
|
-
if (!value || typeof value !== 'object'
|
|
505
|
-
|
|
509
|
+
if (!value || typeof value !== 'object') return []
|
|
510
|
+
const pairs: [unknown, unknown][] = Array.isArray(value)
|
|
511
|
+
? value.map((tier) => [tier?.threshold, tier?.value])
|
|
512
|
+
: Object.entries(value as Record<string, unknown>)
|
|
513
|
+
return pairs
|
|
506
514
|
.map(([threshold, v], i) => ({
|
|
507
515
|
uid: `${idPrefix}-seed-${i}`,
|
|
508
|
-
threshold: String(threshold),
|
|
516
|
+
threshold: threshold === null || threshold === undefined ? '' : String(threshold),
|
|
509
517
|
value: v === null || v === undefined ? '' : String(v),
|
|
510
518
|
}))
|
|
511
519
|
.sort((a, b) => Number(a.threshold) - Number(b.threshold))
|
|
@@ -105,7 +105,10 @@ export function ResourceCombobox<T extends ComboboxOption>({
|
|
|
105
105
|
const { data: searchData } = useQuery({
|
|
106
106
|
queryKey: [queryKey, tenantId, 'search', trimmedQuery],
|
|
107
107
|
queryFn: () => search(trimmedQuery),
|
|
108
|
-
|
|
108
|
+
// The cache key is the picker's own, not the resource's, so a mutation
|
|
109
|
+
// elsewhere (a customer created on another page) never invalidates it.
|
|
110
|
+
// Every search refetches; cached rows for a repeated term still paint first.
|
|
111
|
+
staleTime: 0,
|
|
109
112
|
})
|
|
110
113
|
|
|
111
114
|
// Hydrate the currently selected ID into a record so the trigger shows
|
|
@@ -79,10 +79,8 @@ export function SettingsSidebar({
|
|
|
79
79
|
// the sheet rather than an extension of the nav rail beside it, so it
|
|
80
80
|
// reads as part of the page it is navigating rather than as a second
|
|
81
81
|
// band of chrome.
|
|
82
|
-
'z-30 hidden
|
|
83
|
-
open
|
|
84
|
-
? 'lg:w-(--spacing-sidebar-width) border-e border-border-subtle'
|
|
85
|
-
: 'lg:w-0 border-e-0 border-transparent',
|
|
82
|
+
'z-30 hidden vh-full shrink-0 overflow-hidden bg-muted m-1.5 rounded-lg text-sidebar-foreground transition-[width,border-color] duration-200 ease-out lg:block',
|
|
83
|
+
open ? 'lg:w-(--spacing-sidebar-width)' : 'lg:w-0 border-e-0 border-transparent',
|
|
86
84
|
)}
|
|
87
85
|
>
|
|
88
86
|
<div
|
|
@@ -62,7 +62,7 @@ export function StoreSwitcher() {
|
|
|
62
62
|
return (
|
|
63
63
|
<SidebarMenu>
|
|
64
64
|
<SidebarMenuItem className="h-rail-header-height flex items-center">
|
|
65
|
-
<div className="flex w-full items-center gap-2
|
|
65
|
+
<div className="flex w-full items-center gap-2">{header}</div>
|
|
66
66
|
</SidebarMenuItem>
|
|
67
67
|
</SidebarMenu>
|
|
68
68
|
)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { useDebouncedValue } from '@spree/dashboard-ui'
|
|
1
2
|
import { useQueries } from '@tanstack/react-query'
|
|
2
|
-
import { useEffect, useState } from 'react'
|
|
3
3
|
import { useAuth } from '../hooks/use-auth'
|
|
4
4
|
import { type SearchEntry, useSearchEntries } from '../lib/search-registry'
|
|
5
5
|
import { usePermissions } from '../providers/permission-provider'
|
|
@@ -66,14 +66,3 @@ export function useGlobalSearch(rawQuery: string) {
|
|
|
66
66
|
hasResults: groups.length > 0,
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
|
-
|
|
70
|
-
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
|
71
|
-
const [debounced, setDebounced] = useState(value)
|
|
72
|
-
|
|
73
|
-
useEffect(() => {
|
|
74
|
-
const id = setTimeout(() => setDebounced(value), delayMs)
|
|
75
|
-
return () => clearTimeout(id)
|
|
76
|
-
}, [value, delayMs])
|
|
77
|
-
|
|
78
|
-
return debounced
|
|
79
|
-
}
|
|
@@ -5,7 +5,7 @@ import type { ColumnDef, FilterRule } from './table-registry'
|
|
|
5
5
|
const columns: ColumnDef[] = [
|
|
6
6
|
{ key: 'completed_at', label: 'Date', filterType: 'date' },
|
|
7
7
|
{ key: 'status', label: 'Status', filterType: 'enum', filterOptions: [] },
|
|
8
|
-
{ key: 'tags', label: 'Tags', filterType: 'tags', taggableType: '
|
|
8
|
+
{ key: 'tags', label: 'Tags', filterType: 'tags', taggableType: 'product' },
|
|
9
9
|
{ key: 'sku', label: 'SKU', ransackAttribute: 'master_sku' },
|
|
10
10
|
{
|
|
11
11
|
key: 'companies',
|
package/src/lib/form-errors.ts
CHANGED
|
@@ -71,6 +71,7 @@ function hasTranslation(field: string, entry: ValidationErrorDetail): boolean {
|
|
|
71
71
|
*/
|
|
72
72
|
function translatedSummary(
|
|
73
73
|
details: Record<string, Array<string | ValidationErrorDetail>>,
|
|
74
|
+
fieldAliases?: Record<string, string>,
|
|
74
75
|
): string | null {
|
|
75
76
|
const sentences: string[] = []
|
|
76
77
|
|
|
@@ -85,7 +86,8 @@ function translatedSummary(
|
|
|
85
86
|
// would fall through to the server's own sentence.
|
|
86
87
|
if (!hasTranslation(field, entry)) return null
|
|
87
88
|
|
|
88
|
-
|
|
89
|
+
const labelField = fieldAliases?.[field] ?? field
|
|
90
|
+
sentences.push(`${attributeLabel(labelField)} ${resolveDetailMessage(field, entry)}`.trim())
|
|
89
91
|
}
|
|
90
92
|
}
|
|
91
93
|
|
|
@@ -163,19 +165,30 @@ function attributeLabel(field: string): string {
|
|
|
163
165
|
* the bare `base` key and any `*.base` key as record-level and route them
|
|
164
166
|
* to root.
|
|
165
167
|
*/
|
|
168
|
+
export interface MapSpreeErrorsToFormOptions<TFieldValues extends FieldValues> {
|
|
169
|
+
/**
|
|
170
|
+
* Rails attribute names that differ from the form field the input is bound
|
|
171
|
+
* to. Address validation reports on `state`; the dashboard writes
|
|
172
|
+
* `state_code`.
|
|
173
|
+
*/
|
|
174
|
+
fieldAliases?: Partial<Record<string, Path<TFieldValues>>>
|
|
175
|
+
}
|
|
176
|
+
|
|
166
177
|
export function mapSpreeErrorsToForm<TFieldValues extends FieldValues>(
|
|
167
178
|
error: unknown,
|
|
168
179
|
setError: UseFormSetError<TFieldValues>,
|
|
180
|
+
options?: MapSpreeErrorsToFormOptions<TFieldValues>,
|
|
169
181
|
): boolean {
|
|
170
182
|
if (!(error instanceof SpreeError)) return false
|
|
171
183
|
|
|
172
184
|
const { details, message } = error
|
|
185
|
+
const fieldAliases = options?.fieldAliases as Record<string, string> | undefined
|
|
173
186
|
|
|
174
187
|
// The summary banner is the line a merchant actually reads, so it follows
|
|
175
188
|
// their interface language too. It is rebuilt from the translated entries
|
|
176
189
|
// when every one of them translated; a single untranslated code means the
|
|
177
190
|
// server's own sentence is still the more complete summary, and it stays.
|
|
178
|
-
const summary = (details && translatedSummary(details)) || message
|
|
191
|
+
const summary = (details && translatedSummary(details, fieldAliases)) || message
|
|
179
192
|
if (summary) {
|
|
180
193
|
setError('root' as Path<TFieldValues>, { type: 'server', message: summary })
|
|
181
194
|
}
|
|
@@ -193,9 +206,11 @@ export function mapSpreeErrorsToForm<TFieldValues extends FieldValues>(
|
|
|
193
206
|
// worse failure mode than redundant root messages.
|
|
194
207
|
for (const [field, entries] of Object.entries(details)) {
|
|
195
208
|
if (!entries?.length) continue
|
|
196
|
-
if (!isRenderableFieldKey(field)) continue
|
|
197
209
|
|
|
198
|
-
|
|
210
|
+
const formField = (fieldAliases?.[field] ?? field) as string
|
|
211
|
+
if (!isRenderableFieldKey(formField)) continue
|
|
212
|
+
|
|
213
|
+
setError(formField as Path<TFieldValues>, {
|
|
199
214
|
type: 'server',
|
|
200
215
|
message: entries.map((entry) => resolveDetailMessage(field, entry)).join(', '),
|
|
201
216
|
})
|
package/src/lib/formatters.ts
CHANGED
|
@@ -11,6 +11,10 @@ export function formatStoreDateTime(iso: string, timezone: string) {
|
|
|
11
11
|
return formatInTimeZone(parseISO(iso), timezone, 'PPP p')
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export function formatStoreDate(iso: string, timezone: string) {
|
|
15
|
+
return formatInTimeZone(parseISO(iso), timezone, 'PP')
|
|
16
|
+
}
|
|
17
|
+
|
|
14
18
|
export function getInitials(fullName: string | null | undefined, fallback: string): string {
|
|
15
19
|
const parts = (fullName ?? '').trim().split(/\s+/).filter(Boolean)
|
|
16
20
|
if (parts.length === 0) return fallback.charAt(0)
|
package/src/lib/permissions.ts
CHANGED
|
@@ -1,90 +1,89 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Permission subjects — use these constants instead of raw strings to avoid
|
|
3
|
+
* typos in permission checks.
|
|
4
4
|
*
|
|
5
|
-
* Matches the
|
|
5
|
+
* Matches the short names the `/me` endpoints serialize (`product`,
|
|
6
|
+
* `customer`), the same shorthand the rest of the API uses for types.
|
|
6
7
|
*/
|
|
7
8
|
export const Subject = {
|
|
8
9
|
All: 'all',
|
|
9
|
-
Product: '
|
|
10
|
-
ProductType: '
|
|
10
|
+
Product: 'product',
|
|
11
|
+
ProductType: 'product_type',
|
|
11
12
|
// The media library. Authorized as product imagery — a merchant who can edit
|
|
12
13
|
// products can manage the files that illustrate them.
|
|
13
|
-
Media: '
|
|
14
|
-
Variant: '
|
|
15
|
-
Order: '
|
|
16
|
-
SavedReport: '
|
|
17
|
-
ReportExport: '
|
|
18
|
-
Customer: '
|
|
19
|
-
CustomerGroup: '
|
|
20
|
-
AdminUser: '
|
|
21
|
-
ApiKey: '
|
|
22
|
-
AllowedOrigin: '
|
|
23
|
-
Store: '
|
|
14
|
+
Media: 'media',
|
|
15
|
+
Variant: 'variant',
|
|
16
|
+
Order: 'order',
|
|
17
|
+
SavedReport: 'saved_report',
|
|
18
|
+
ReportExport: 'report',
|
|
19
|
+
Customer: 'customer',
|
|
20
|
+
CustomerGroup: 'customer_group',
|
|
21
|
+
AdminUser: 'admin_user',
|
|
22
|
+
ApiKey: 'api_key',
|
|
23
|
+
AllowedOrigin: 'allowed_origin',
|
|
24
|
+
Store: 'store',
|
|
24
25
|
// The store's packaging vocabulary: the box it ships parcels in, and the
|
|
25
26
|
// cartons, pallets and containers a wholesale order leaves on.
|
|
26
|
-
PackageType: '
|
|
27
|
-
Channel: '
|
|
28
|
-
OrderRoutingRule: '
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
DeliveryMethod: 'Spree::DeliveryMethod',
|
|
54
|
-
DeliveryZone: 'Spree::DeliveryZone',
|
|
55
|
-
DeliveryProfile: 'Spree::DeliveryProfile',
|
|
27
|
+
PackageType: 'package_type',
|
|
28
|
+
Channel: 'channel',
|
|
29
|
+
OrderRoutingRule: 'order_routing_rule',
|
|
30
|
+
Category: 'category',
|
|
31
|
+
Collection: 'collection',
|
|
32
|
+
OptionType: 'option_type',
|
|
33
|
+
OptionValue: 'option_value',
|
|
34
|
+
Policy: 'policy',
|
|
35
|
+
TaxCategory: 'tax_category',
|
|
36
|
+
TaxRate: 'tax_rate',
|
|
37
|
+
TaxIdentifier: 'tax_identifier',
|
|
38
|
+
TaxExemptionCertificate: 'tax_exemption_certificate',
|
|
39
|
+
Company: 'company',
|
|
40
|
+
CompanyAddress: 'company_address',
|
|
41
|
+
CompanyMembership: 'company_membership',
|
|
42
|
+
CompanyInvitation: 'company_invitation',
|
|
43
|
+
Catalog: 'catalog',
|
|
44
|
+
ReturnReason: 'return_reason',
|
|
45
|
+
ClaimReason: 'claim_reason',
|
|
46
|
+
RefundReason: 'refund_reason',
|
|
47
|
+
OrderCancellationReason: 'order_cancellation_reason',
|
|
48
|
+
CustomFieldDefinition: 'custom_field_definition',
|
|
49
|
+
Integration: 'integration',
|
|
50
|
+
PaymentMethod: 'payment_method',
|
|
51
|
+
DeliveryMethod: 'delivery_method',
|
|
52
|
+
DeliveryZone: 'delivery_zone',
|
|
53
|
+
DeliveryProfile: 'delivery_profile',
|
|
56
54
|
/** @deprecated Use Subject.DeliveryMethod — removed in Spree 6.1. */
|
|
57
|
-
ShippingMethod: '
|
|
58
|
-
StockLocation: '
|
|
59
|
-
StockLevel: '
|
|
55
|
+
ShippingMethod: 'delivery_method',
|
|
56
|
+
StockLocation: 'stock_location',
|
|
57
|
+
StockLevel: 'stock_level',
|
|
60
58
|
/** @deprecated Use Subject.StockLevel — removed in Spree 6.1. */
|
|
61
|
-
StockItem: '
|
|
62
|
-
StockTransfer: '
|
|
63
|
-
StockTransferItem: '
|
|
64
|
-
Supplier: '
|
|
65
|
-
PurchaseOrder: '
|
|
66
|
-
PurchaseOrderItem: '
|
|
67
|
-
PriceList: '
|
|
68
|
-
PriceRule: '
|
|
69
|
-
Promotion: '
|
|
70
|
-
PromotionAction: '
|
|
71
|
-
PromotionRule: '
|
|
72
|
-
GiftCard: '
|
|
73
|
-
GiftCardBatch: '
|
|
74
|
-
StoreCredit: '
|
|
75
|
-
StoreCreditEvent: '
|
|
76
|
-
Role: '
|
|
77
|
-
Invitation: '
|
|
78
|
-
Market: '
|
|
79
|
-
WebhookEndpoint: '
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
59
|
+
StockItem: 'stock_level',
|
|
60
|
+
StockTransfer: 'stock_transfer',
|
|
61
|
+
StockTransferItem: 'stock_transfer_item',
|
|
62
|
+
Supplier: 'supplier',
|
|
63
|
+
PurchaseOrder: 'purchase_order',
|
|
64
|
+
PurchaseOrderItem: 'purchase_order_item',
|
|
65
|
+
PriceList: 'price_list',
|
|
66
|
+
PriceRule: 'price_rule',
|
|
67
|
+
Promotion: 'promotion',
|
|
68
|
+
PromotionAction: 'promotion_action',
|
|
69
|
+
PromotionRule: 'promotion_rule',
|
|
70
|
+
GiftCard: 'gift_card',
|
|
71
|
+
GiftCardBatch: 'gift_card_batch',
|
|
72
|
+
StoreCredit: 'store_credit',
|
|
73
|
+
StoreCreditEvent: 'store_credit_event',
|
|
74
|
+
Role: 'role',
|
|
75
|
+
Invitation: 'invitation',
|
|
76
|
+
Market: 'market',
|
|
77
|
+
WebhookEndpoint: 'webhook_endpoint',
|
|
78
|
+
EmailTemplate: 'email_template',
|
|
79
|
+
WebhookDelivery: 'webhook_delivery',
|
|
80
|
+
Wishlist: 'wishlist',
|
|
81
|
+
Seller: 'seller',
|
|
82
|
+
CommissionRate: 'commission_rate',
|
|
83
|
+
CommissionRule: 'commission_rule',
|
|
84
|
+
CommissionLine: 'commission_line',
|
|
85
|
+
SellerPayout: 'seller_payout',
|
|
86
|
+
SellerTransfer: 'seller_transfer',
|
|
88
87
|
} as const
|
|
89
88
|
|
|
90
89
|
export type SubjectName = (typeof Subject)[keyof typeof Subject] | string
|
|
@@ -84,14 +84,12 @@ export interface ResourceFilterConfig<R extends { id: string } = { id: string }>
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
|
-
*
|
|
88
|
-
* first-class taggables
|
|
89
|
-
*
|
|
90
|
-
* `
|
|
91
|
-
* server-side to extend). Use `Subject.Product` etc. to avoid stringly-typed
|
|
92
|
-
* callsites for the built-ins.
|
|
87
|
+
* Resources that `TagCombobox` can target for tag autocomplete, by API
|
|
88
|
+
* shorthand. The three first-class taggables get autocomplete hints, but the
|
|
89
|
+
* union is open — apps can pass the shorthand of any type registered in
|
|
90
|
+
* `Spree.taggable_types` on the server.
|
|
93
91
|
*/
|
|
94
|
-
export type TaggableType = '
|
|
92
|
+
export type TaggableType = 'product' | 'customer' | 'order' | (string & {})
|
|
95
93
|
|
|
96
94
|
/**
|
|
97
95
|
* Column definition. Discriminated union on `filterType`:
|
package/src/locales/ar.json
CHANGED
|
@@ -1401,7 +1401,7 @@
|
|
|
1401
1401
|
"create_description": "أضف مستودعًا أو متجرًا أو نقطة استلام لتلبية الطلبات منها.",
|
|
1402
1402
|
"create_label": "إنشاء موقع المخزون",
|
|
1403
1403
|
"delete_confirm": {
|
|
1404
|
-
"message": "ستتم إزالة {{name}}.
|
|
1404
|
+
"message": "ستتم إزالة {{name}}. سيتم حذف مستويات المخزون في هذا الموقع أيضًا.",
|
|
1405
1405
|
"title": "هل تريد حذف موقع المخزون؟"
|
|
1406
1406
|
},
|
|
1407
1407
|
"edit_description": "تحديث الاسم أو العنوان أو الالتقاط أو سياسات المخزون.",
|
|
@@ -1575,6 +1575,12 @@
|
|
|
1575
1575
|
"slug_placeholder": "product-url-handle"
|
|
1576
1576
|
},
|
|
1577
1577
|
"tax_category_placeholder": "Select tax category",
|
|
1578
|
+
"tax": {
|
|
1579
|
+
"apply_to_variants_help": "تنطبق هذه الفئة على كل متغير ليس له فئة ضريبة خاصة به. عند الحفظ، تستخدم هذه المتغيرات هذا الإعداد.",
|
|
1580
|
+
"variant_overrides_heading": "متغيرات لها فئة ضريبة خاصة بها",
|
|
1581
|
+
"variant_overrides_description": "تحتفظ هذه المتغيرات بفئتها حتى تزيل الاستثناء. تتبع بقية المتغيرات فئة المنتج أعلاه.",
|
|
1582
|
+
"clear_variant_override": "استخدام فئة المنتج"
|
|
1583
|
+
},
|
|
1578
1584
|
"transfers": {
|
|
1579
1585
|
"create_description": "انقل المخزون بين المواقع، أو استلم مخزونًا جديدًا من مورّد خارجي.",
|
|
1580
1586
|
"delete_confirm": {
|
package/src/locales/de.json
CHANGED
|
@@ -1401,7 +1401,7 @@
|
|
|
1401
1401
|
"create_description": "Fügen Sie ein Lager, ein Geschäft oder einen Abholpunkt hinzu, von dem aus Bestellungen abgewickelt werden.",
|
|
1402
1402
|
"create_label": "Lagerort erstellen",
|
|
1403
1403
|
"delete_confirm": {
|
|
1404
|
-
"message": "{{name}} wird entfernt.
|
|
1404
|
+
"message": "{{name}} wird entfernt. Bestände an diesem Standort werden ebenfalls gelöscht.",
|
|
1405
1405
|
"title": "Lagerort löschen?"
|
|
1406
1406
|
},
|
|
1407
1407
|
"edit_description": "Aktualisieren Sie Name, Adresse, Abholung oder Bestandsrichtlinien.",
|
|
@@ -1576,6 +1576,12 @@
|
|
|
1576
1576
|
"slug_placeholder": "produkt-url-handle"
|
|
1577
1577
|
},
|
|
1578
1578
|
"tax_category_placeholder": "Steuerkategorie auswählen",
|
|
1579
|
+
"tax": {
|
|
1580
|
+
"apply_to_variants_help": "Diese Kategorie gilt für alle Varianten ohne eigene Steuerkategorie. Beim Speichern übernehmen diese Varianten diese Einstellung.",
|
|
1581
|
+
"variant_overrides_heading": "Varianten mit eigener Steuerkategorie",
|
|
1582
|
+
"variant_overrides_description": "Diese Varianten behalten ihre Kategorie, bis Sie die Ausnahme aufheben. Alle anderen Varianten folgen der Produktkategorie oben.",
|
|
1583
|
+
"clear_variant_override": "Produktkategorie verwenden"
|
|
1584
|
+
},
|
|
1579
1585
|
"transfers": {
|
|
1580
1586
|
"create_description": "Bestand zwischen Lagern bewegen oder neuen Bestand von einem externen Lieferanten annehmen.",
|
|
1581
1587
|
"delete_confirm": {
|
package/src/locales/en.json
CHANGED
|
@@ -1403,7 +1403,7 @@
|
|
|
1403
1403
|
"create_description": "Add a warehouse, store, or pickup point to fulfill orders from.",
|
|
1404
1404
|
"create_label": "Create stock location",
|
|
1405
1405
|
"delete_confirm": {
|
|
1406
|
-
"message": "{{name}} will be removed. Stock levels at this location
|
|
1406
|
+
"message": "{{name}} will be removed. Stock levels at this location will be deleted as well.",
|
|
1407
1407
|
"title": "Delete stock location?"
|
|
1408
1408
|
},
|
|
1409
1409
|
"edit_description": "Update name, address, pickup, or stock policies.",
|
|
@@ -1578,6 +1578,12 @@
|
|
|
1578
1578
|
"slug_placeholder": "product-url-handle"
|
|
1579
1579
|
},
|
|
1580
1580
|
"tax_category_placeholder": "Select tax category",
|
|
1581
|
+
"tax": {
|
|
1582
|
+
"apply_to_variants_help": "This category applies to every variant that does not have its own tax category. When you save, those variants use this setting.",
|
|
1583
|
+
"variant_overrides_heading": "Variants with their own tax category",
|
|
1584
|
+
"variant_overrides_description": "These variants keep their category until you clear the override. All other variants follow the product category above.",
|
|
1585
|
+
"clear_variant_override": "Use product category"
|
|
1586
|
+
},
|
|
1581
1587
|
"transfers": {
|
|
1582
1588
|
"create_description": "Move stock between locations, or receive new stock from an external supplier.",
|
|
1583
1589
|
"delete_confirm": {
|
package/src/locales/es.json
CHANGED
|
@@ -1064,6 +1064,12 @@
|
|
|
1064
1064
|
"slug_placeholder": "url-del-producto"
|
|
1065
1065
|
},
|
|
1066
1066
|
"tax_category_placeholder": "Seleccionar categoría de impuestos",
|
|
1067
|
+
"tax": {
|
|
1068
|
+
"apply_to_variants_help": "Esta categoría se aplica a cada variante que no tenga su propia categoría fiscal. Al guardar, esas variantes usan esta configuración.",
|
|
1069
|
+
"variant_overrides_heading": "Variantes con categoría fiscal propia",
|
|
1070
|
+
"variant_overrides_description": "Estas variantes conservan su categoría hasta que quite la excepción. El resto siguen la categoría del producto de arriba.",
|
|
1071
|
+
"clear_variant_override": "Usar categoría del producto"
|
|
1072
|
+
},
|
|
1067
1073
|
"transfers": {
|
|
1068
1074
|
"create_description": "Mové stock entre ubicaciones o recibí stock nuevo de un proveedor externo.",
|
|
1069
1075
|
"delete_confirm": {
|
|
@@ -1540,7 +1546,7 @@
|
|
|
1540
1546
|
"create_description": "Agregá un depósito, un local o un punto de retiro desde el que cumplir pedidos.",
|
|
1541
1547
|
"create_label": "Crear ubicación de inventario",
|
|
1542
1548
|
"delete_confirm": {
|
|
1543
|
-
"message": "{{name}} se eliminará. Los niveles de stock de esta ubicación
|
|
1549
|
+
"message": "{{name}} se eliminará. Los niveles de stock de esta ubicación también se eliminarán.",
|
|
1544
1550
|
"title": "¿Eliminar ubicación de inventario?"
|
|
1545
1551
|
},
|
|
1546
1552
|
"edit_description": "Actualizá el nombre, la dirección, el retiro o las políticas de stock.",
|
package/src/locales/fr.json
CHANGED
|
@@ -1401,7 +1401,7 @@
|
|
|
1401
1401
|
"create_description": "Ajoutez un entrepôt, un magasin ou un point de retrait pour traiter les commandes.",
|
|
1402
1402
|
"create_label": "Créer un emplacement de stock",
|
|
1403
1403
|
"delete_confirm": {
|
|
1404
|
-
"message": "{{name}} sera supprimé. Les
|
|
1404
|
+
"message": "{{name}} sera supprimé. Les niveaux de stock à cet emplacement seront également supprimés.",
|
|
1405
1405
|
"title": "Supprimer l'emplacement de stock ?"
|
|
1406
1406
|
},
|
|
1407
1407
|
"edit_description": "Mettez à jour le nom, l'adresse, le retrait ou les politiques de stock.",
|
|
@@ -1576,6 +1576,12 @@
|
|
|
1576
1576
|
"slug_placeholder": "url-du-produit"
|
|
1577
1577
|
},
|
|
1578
1578
|
"tax_category_placeholder": "Sélectionner une catégorie de taxe",
|
|
1579
|
+
"tax": {
|
|
1580
|
+
"apply_to_variants_help": "Cette catégorie s'applique à chaque variante qui n'a pas sa propre catégorie de taxe. À l'enregistrement, ces variantes utilisent ce réglage.",
|
|
1581
|
+
"variant_overrides_heading": "Variantes avec leur propre catégorie de taxe",
|
|
1582
|
+
"variant_overrides_description": "Ces variantes conservent leur catégorie jusqu'à ce que vous supprimiez l'exception. Les autres variantes suivent la catégorie du produit ci-dessus.",
|
|
1583
|
+
"clear_variant_override": "Utiliser la catégorie du produit"
|
|
1584
|
+
},
|
|
1579
1585
|
"transfers": {
|
|
1580
1586
|
"create_description": "Déplacer du stock entre les emplacements, ou recevoir du nouveau stock d’un fournisseur externe.",
|
|
1581
1587
|
"delete_confirm": {
|
package/src/locales/pl.json
CHANGED
|
@@ -1418,7 +1418,7 @@
|
|
|
1418
1418
|
"create_description": "Dodaj magazyn, sklep lub punkt odbioru, z którego będą realizowane zamówienia.",
|
|
1419
1419
|
"create_label": "Utwórz lokalizację magazynową",
|
|
1420
1420
|
"delete_confirm": {
|
|
1421
|
-
"message": "{{name}} zostanie usunięta.
|
|
1421
|
+
"message": "{{name}} zostanie usunięta. Poziomy zapasów w tej lokalizacji również zostaną usunięte.",
|
|
1422
1422
|
"title": "Usunąć lokalizację magazynową?"
|
|
1423
1423
|
},
|
|
1424
1424
|
"edit_description": "Zaktualizuj nazwę, adres, odbiór lub zasady dotyczące zapasów.",
|
|
@@ -1597,6 +1597,12 @@
|
|
|
1597
1597
|
"slug_placeholder": "adres-url-produktu"
|
|
1598
1598
|
},
|
|
1599
1599
|
"tax_category_placeholder": "Wybierz kategorię podatkową",
|
|
1600
|
+
"tax": {
|
|
1601
|
+
"apply_to_variants_help": "Ta kategoria dotyczy każdego wariantu bez własnej kategorii podatkowej. Po zapisaniu te warianty korzystają z tego ustawienia.",
|
|
1602
|
+
"variant_overrides_heading": "Warianty z własną kategorią podatkową",
|
|
1603
|
+
"variant_overrides_description": "Te warianty zachowują swoją kategorię, dopóki nie usuniesz wyjątku. Pozostałe warianty stosują kategorię produktu powyżej.",
|
|
1604
|
+
"clear_variant_override": "Użyj kategorii produktu"
|
|
1605
|
+
},
|
|
1600
1606
|
"transfers": {
|
|
1601
1607
|
"create_description": "Przenieś zapasy między lokalizacjami lub przyjmij nowy towar od zewnętrznego dostawcy.",
|
|
1602
1608
|
"delete_confirm": {
|
package/src/locales/zh-CN.json
CHANGED
|
@@ -1401,7 +1401,7 @@
|
|
|
1401
1401
|
"create_description": "添加仓库、门店或自提点,用于履行订单。",
|
|
1402
1402
|
"create_label": "创建库存地点",
|
|
1403
1403
|
"delete_confirm": {
|
|
1404
|
-
"message": "{{name}}
|
|
1404
|
+
"message": "{{name}} 将被移除。此地点的库存水平也将被删除。",
|
|
1405
1405
|
"title": "删除库存地点?"
|
|
1406
1406
|
},
|
|
1407
1407
|
"edit_description": "更新名称、地址、自提或库存策略。",
|
|
@@ -1575,6 +1575,12 @@
|
|
|
1575
1575
|
"slug_placeholder": "product-url-handle"
|
|
1576
1576
|
},
|
|
1577
1577
|
"tax_category_placeholder": "选择税务类别",
|
|
1578
|
+
"tax": {
|
|
1579
|
+
"apply_to_variants_help": "此分类适用于未设置专属税务分类的每个变体。保存后,这些变体将使用此设置。",
|
|
1580
|
+
"variant_overrides_heading": "已设置专属税务分类的变体",
|
|
1581
|
+
"variant_overrides_description": "这些变体将保留其分类,直到您清除覆盖。其他变体遵循上方的产品分类。",
|
|
1582
|
+
"clear_variant_override": "使用产品分类"
|
|
1583
|
+
},
|
|
1578
1584
|
"transfers": {
|
|
1579
1585
|
"create_description": "在库存地点之间调拨库存,或从外部供应商收货。",
|
|
1580
1586
|
"delete_confirm": {
|
|
@@ -88,7 +88,7 @@ function SettingsCard({ entry, tenantId }: { entry: SettingsNavEntry; tenantId:
|
|
|
88
88
|
const description = resolveNavDescription(entry, t)
|
|
89
89
|
|
|
90
90
|
return (
|
|
91
|
-
<Card className="p-0 transition-colors hover:bg-accent/80 hover:border-border">
|
|
91
|
+
<Card className="p-0 transition-colors border-border-subtle hover:bg-accent/80 hover:border-border">
|
|
92
92
|
{/* The whole card is the target — a small title-only link would fail the
|
|
93
93
|
44px minimum on touch and leaves most of the card dead to a click. */}
|
|
94
94
|
{/* Paths come from a runtime registry plugins extend, so they can't be
|
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
useOptionalStore,
|
|
25
25
|
} from '@spree/dashboard-core'
|
|
26
26
|
import {
|
|
27
|
+
Alert,
|
|
28
|
+
AlertDescription,
|
|
27
29
|
Button,
|
|
28
30
|
Card,
|
|
29
31
|
CardAction,
|
|
@@ -59,6 +61,7 @@ import {
|
|
|
59
61
|
FolderTreeIcon,
|
|
60
62
|
ImageIcon,
|
|
61
63
|
ImagePlusIcon,
|
|
64
|
+
InfoIcon,
|
|
62
65
|
LibraryIcon,
|
|
63
66
|
Loader2Icon,
|
|
64
67
|
PencilIcon,
|
|
@@ -69,12 +72,20 @@ import {
|
|
|
69
72
|
WarehouseIcon,
|
|
70
73
|
} from '@spree/dashboard-ui/icons'
|
|
71
74
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
72
|
-
import {
|
|
75
|
+
import {
|
|
76
|
+
type Control,
|
|
77
|
+
Controller,
|
|
78
|
+
type UseFormReturn,
|
|
79
|
+
useFormState,
|
|
80
|
+
useWatch,
|
|
81
|
+
} from 'react-hook-form'
|
|
73
82
|
import { useTranslation } from 'react-i18next'
|
|
74
83
|
import { AddVideoDialog } from './add-video-dialog'
|
|
75
84
|
import { InventorySection } from './inventory-section'
|
|
76
85
|
import { MediaEditSheet } from './media-edit-sheet'
|
|
77
86
|
import { ProductBulkPriceEditor } from './product-bulk-price-editor'
|
|
87
|
+
import { variantHasTaxCategoryOverride } from './product-form-mapping'
|
|
88
|
+
import type { VariantFormValues } from './product-schema'
|
|
78
89
|
import type {
|
|
79
90
|
PanelMedia as Media,
|
|
80
91
|
PanelProduct as Product,
|
|
@@ -969,13 +980,31 @@ export function CategorizationCard({ form }: FormCardProps) {
|
|
|
969
980
|
.filter(Boolean)
|
|
970
981
|
}, [selectedProductType?.option_type_labels, selectedProductType?.option_type_ids, optionTypes])
|
|
971
982
|
|
|
983
|
+
// Seed type categories only when the merchant picks or changes the product
|
|
984
|
+
// type — not when an existing product opens and the type record loads async.
|
|
985
|
+
const previousProductTypeIdRef = useRef<string | null | undefined>(undefined)
|
|
986
|
+
|
|
972
987
|
useEffect(() => {
|
|
973
988
|
// Only where the panel files products at all: a seller's client registers
|
|
974
989
|
// no categories, so seeding them would dirty the form with ids their API
|
|
975
990
|
// drops on save — an unsaved-changes prompt over a field they cannot see.
|
|
976
991
|
if (!client.categories) return
|
|
977
992
|
|
|
978
|
-
const
|
|
993
|
+
const currentTypeId = selectedProductTypeId ?? null
|
|
994
|
+
|
|
995
|
+
if (previousProductTypeIdRef.current === undefined) {
|
|
996
|
+
previousProductTypeIdRef.current = currentTypeId
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
if (!currentTypeId || selectedProductType?.id !== currentTypeId) return
|
|
1001
|
+
|
|
1002
|
+
const typeChanged = previousProductTypeIdRef.current !== currentTypeId
|
|
1003
|
+
if (!typeChanged) return
|
|
1004
|
+
|
|
1005
|
+
previousProductTypeIdRef.current = currentTypeId
|
|
1006
|
+
|
|
1007
|
+
const categoryIds = selectedProductType.category_ids
|
|
979
1008
|
if (!categoryIds?.length) return
|
|
980
1009
|
|
|
981
1010
|
const current = (form.getValues('category_ids') as string[] | undefined) ?? []
|
|
@@ -983,7 +1012,7 @@ export function CategorizationCard({ form }: FormCardProps) {
|
|
|
983
1012
|
if (missing.length === 0) return
|
|
984
1013
|
|
|
985
1014
|
form.setValue('category_ids', [...current, ...missing], { shouldDirty: true })
|
|
986
|
-
}, [selectedProductType, form, client.categories])
|
|
1015
|
+
}, [selectedProductTypeId, selectedProductType, form, client.categories])
|
|
987
1016
|
// Automatic collections rebuild their members from rules, so a hand-picked
|
|
988
1017
|
// membership would be dropped on the next regeneration — offer manual only.
|
|
989
1018
|
// Memoized: `initialItems` feeds a useEffect + useMemo inside
|
|
@@ -1128,7 +1157,7 @@ export function CategorizationCard({ form }: FormCardProps) {
|
|
|
1128
1157
|
control={form.control}
|
|
1129
1158
|
render={({ field }) => (
|
|
1130
1159
|
<TagCombobox
|
|
1131
|
-
taggableType="
|
|
1160
|
+
taggableType="product"
|
|
1132
1161
|
value={field.value ?? []}
|
|
1133
1162
|
onChange={field.onChange}
|
|
1134
1163
|
/>
|
|
@@ -1149,6 +1178,27 @@ export function TaxCard({ form }: FormCardProps) {
|
|
|
1149
1178
|
const { t } = useTranslation()
|
|
1150
1179
|
const { data: taxCategoriesResponse } = useTaxCategories()
|
|
1151
1180
|
const taxCategories = taxCategoriesResponse?.data ?? []
|
|
1181
|
+
const variants = useWatch({ control: form.control, name: 'variants' }) ?? []
|
|
1182
|
+
const { dirtyFields } = useFormState({ control: form.control, name: 'tax_category_id' })
|
|
1183
|
+
const showApplyToVariantsNotice = Boolean(dirtyFields.tax_category_id) && variants.length > 1
|
|
1184
|
+
const overrides = useMemo(
|
|
1185
|
+
() =>
|
|
1186
|
+
variants
|
|
1187
|
+
.map((variant, index) => ({ variant, index }))
|
|
1188
|
+
.filter(({ variant }) => variantHasTaxCategoryOverride(variant)),
|
|
1189
|
+
[variants],
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
const clearVariantTaxOverride = (variantIndex: number) => {
|
|
1193
|
+
form.setValue(`variants.${variantIndex}.tax_category_id`, null, { shouldDirty: true })
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const labelForVariant = (variant: VariantFormValues) => {
|
|
1197
|
+
if (variant.options.length > 0) {
|
|
1198
|
+
return variant.options.map((option) => `${option.name}: ${option.value}`).join(', ')
|
|
1199
|
+
}
|
|
1200
|
+
return variant.sku?.trim() || t('admin.products.variants.default_variant')
|
|
1201
|
+
}
|
|
1152
1202
|
|
|
1153
1203
|
return (
|
|
1154
1204
|
<Card>
|
|
@@ -1158,7 +1208,7 @@ export function TaxCard({ form }: FormCardProps) {
|
|
|
1158
1208
|
{t('admin.fields.tax.label')}
|
|
1159
1209
|
</CardTitle>
|
|
1160
1210
|
</CardHeader>
|
|
1161
|
-
<CardContent>
|
|
1211
|
+
<CardContent className="space-y-4">
|
|
1162
1212
|
<Field>
|
|
1163
1213
|
<FieldLabel>{t('admin.fields.tax_category_id.label')}</FieldLabel>
|
|
1164
1214
|
<Controller
|
|
@@ -1185,6 +1235,48 @@ export function TaxCard({ form }: FormCardProps) {
|
|
|
1185
1235
|
)}
|
|
1186
1236
|
/>
|
|
1187
1237
|
</Field>
|
|
1238
|
+
|
|
1239
|
+
{showApplyToVariantsNotice && (
|
|
1240
|
+
<Alert variant="info">
|
|
1241
|
+
<InfoIcon />
|
|
1242
|
+
<AlertDescription>{t('admin.products.tax.apply_to_variants_help')}</AlertDescription>
|
|
1243
|
+
</Alert>
|
|
1244
|
+
)}
|
|
1245
|
+
|
|
1246
|
+
{overrides.length > 0 && (
|
|
1247
|
+
<div className="rounded-md border bg-muted/30 p-3 text-sm">
|
|
1248
|
+
<p className="font-medium">{t('admin.products.tax.variant_overrides_heading')}</p>
|
|
1249
|
+
<p className="text-muted-foreground mt-1">
|
|
1250
|
+
{t('admin.products.tax.variant_overrides_description')}
|
|
1251
|
+
</p>
|
|
1252
|
+
<ul className="mt-3 space-y-2">
|
|
1253
|
+
{overrides.map(({ variant, index }) => {
|
|
1254
|
+
const categoryName =
|
|
1255
|
+
taxCategories.find((c) => c.id === variant.tax_category_id)?.name ??
|
|
1256
|
+
variant.tax_category_id
|
|
1257
|
+
return (
|
|
1258
|
+
<li
|
|
1259
|
+
key={variant.id ?? `variant-tax-${index}`}
|
|
1260
|
+
className="flex flex-wrap items-center justify-between gap-2"
|
|
1261
|
+
>
|
|
1262
|
+
<span>
|
|
1263
|
+
{labelForVariant(variant)}
|
|
1264
|
+
<span className="text-muted-foreground"> — {categoryName}</span>
|
|
1265
|
+
</span>
|
|
1266
|
+
<Button
|
|
1267
|
+
type="button"
|
|
1268
|
+
variant="outline"
|
|
1269
|
+
size="sm"
|
|
1270
|
+
onClick={() => clearVariantTaxOverride(index)}
|
|
1271
|
+
>
|
|
1272
|
+
{t('admin.products.tax.clear_variant_override')}
|
|
1273
|
+
</Button>
|
|
1274
|
+
</li>
|
|
1275
|
+
)
|
|
1276
|
+
})}
|
|
1277
|
+
</ul>
|
|
1278
|
+
</div>
|
|
1279
|
+
)}
|
|
1188
1280
|
</CardContent>
|
|
1189
1281
|
</Card>
|
|
1190
1282
|
)
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
prepareVariantsTaxForProductSave,
|
|
4
|
+
variantHasTaxCategoryOverride,
|
|
5
|
+
variantToFormValues,
|
|
6
|
+
variantToWirePayload,
|
|
7
|
+
} from './product-form-mapping'
|
|
8
|
+
import type { VariantFormValues } from './product-schema'
|
|
3
9
|
import type { PanelVariant } from './product-types'
|
|
4
10
|
|
|
5
11
|
// variantToFormValues reads a handful of fields; cast a minimal fixture rather
|
|
@@ -70,3 +76,56 @@ describe('variantToFormValues prices', () => {
|
|
|
70
76
|
expect(values.prices).toEqual([])
|
|
71
77
|
})
|
|
72
78
|
})
|
|
79
|
+
|
|
80
|
+
describe('variant tax category overrides', () => {
|
|
81
|
+
const baseVariant = (): VariantFormValues => ({
|
|
82
|
+
id: 'variant_1',
|
|
83
|
+
sku: 'SKU-1',
|
|
84
|
+
barcode: null,
|
|
85
|
+
position: 0,
|
|
86
|
+
options: [],
|
|
87
|
+
weight: null,
|
|
88
|
+
height: null,
|
|
89
|
+
width: null,
|
|
90
|
+
depth: null,
|
|
91
|
+
weight_unit: null,
|
|
92
|
+
dimensions_unit: null,
|
|
93
|
+
hs_code: null,
|
|
94
|
+
country_of_origin: null,
|
|
95
|
+
customs_description: null,
|
|
96
|
+
minimum_order_quantity: null,
|
|
97
|
+
order_multiple: null,
|
|
98
|
+
purchase_unit: null,
|
|
99
|
+
units_per_carton: null,
|
|
100
|
+
cartons_per_pallet: null,
|
|
101
|
+
carton_weight: null,
|
|
102
|
+
carton_package_type_id: null,
|
|
103
|
+
track_inventory: true,
|
|
104
|
+
preorderable: false,
|
|
105
|
+
preorder_ships_at: null,
|
|
106
|
+
backorder_limit: null,
|
|
107
|
+
tax_category_id: null,
|
|
108
|
+
prices: [],
|
|
109
|
+
stock_levels: [],
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('detects a stored override', () => {
|
|
113
|
+
expect(variantHasTaxCategoryOverride({ ...baseVariant(), tax_category_id: 'taxcat_1' })).toBe(
|
|
114
|
+
true,
|
|
115
|
+
)
|
|
116
|
+
expect(variantHasTaxCategoryOverride(baseVariant())).toBe(false)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('clears inheriting variants and keeps overrides on save prep', () => {
|
|
120
|
+
const variants = [
|
|
121
|
+
{ ...baseVariant(), tax_category_id: null },
|
|
122
|
+
{ ...baseVariant(), id: 'variant_2', tax_category_id: 'taxcat_override' },
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
const prepared = prepareVariantsTaxForProductSave(variants, 'taxcat_product')
|
|
126
|
+
|
|
127
|
+
expect(prepared[0].tax_category_id).toBeNull()
|
|
128
|
+
expect(prepared[1].tax_category_id).toBe('taxcat_override')
|
|
129
|
+
expect(variantToWirePayload(prepared[0], 0).tax_category_id).toBeNull()
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -162,6 +162,27 @@ export function productToFormValues(
|
|
|
162
162
|
// `index` is the variant's array position; we ship `index + 1` so
|
|
163
163
|
// `acts_as_list` persists the 1-indexed order. Form state stays 0-indexed
|
|
164
164
|
// (matches the React array), the API quirk lives only at this boundary.
|
|
165
|
+
/** Variants with a non-null tax category id carry their own category on save. */
|
|
166
|
+
export function variantHasTaxCategoryOverride(variant: VariantFormValues): boolean {
|
|
167
|
+
return variant.tax_category_id != null && variant.tax_category_id !== ''
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Before PATCH, keep overrides as-is and leave inheriting variants at null so
|
|
172
|
+
* the product-level tax category applies through Variant#tax_category.
|
|
173
|
+
*/
|
|
174
|
+
export function prepareVariantsTaxForProductSave(
|
|
175
|
+
variants: VariantFormValues[],
|
|
176
|
+
_productTaxCategoryId: string | null | undefined,
|
|
177
|
+
): VariantFormValues[] {
|
|
178
|
+
return variants.map((variant) => {
|
|
179
|
+
if (variantHasTaxCategoryOverride(variant)) {
|
|
180
|
+
return variant
|
|
181
|
+
}
|
|
182
|
+
return { ...variant, tax_category_id: null }
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
165
186
|
export function variantToWirePayload(v: VariantFormValues, index: number) {
|
|
166
187
|
// DB columns `sku` and `weight` are NOT NULL with defaults ("", 0.0).
|
|
167
188
|
// The other scalar fields (barcode, dimensions, weight_unit,
|
|
@@ -121,7 +121,13 @@ export function PermissionProvider({ children }: { children: ReactNode }) {
|
|
|
121
121
|
// forever. Depend on the tenant id (a string) and rebuild the key inside.
|
|
122
122
|
const tenantId = useTenantId()
|
|
123
123
|
const refresh = useCallback(async () => {
|
|
124
|
-
|
|
124
|
+
const permissionsKey = [PERMISSIONS_RESOURCE, tenantId]
|
|
125
|
+
// A refresh asks for the rules as they stand now. A first fetch still in
|
|
126
|
+
// flight was sent before — in the seller panel, before any seller was
|
|
127
|
+
// chosen — and with nothing cached TanStack hands that same fetch back
|
|
128
|
+
// rather than starting another, so it is cancelled first.
|
|
129
|
+
await queryClient.cancelQueries({ queryKey: permissionsKey })
|
|
130
|
+
await queryClient.invalidateQueries({ queryKey: permissionsKey })
|
|
125
131
|
}, [queryClient, tenantId])
|
|
126
132
|
|
|
127
133
|
const permissions = useMemo(
|
package/src/vite/discover.ts
CHANGED
|
@@ -125,6 +125,27 @@ export function discoverDashboardPlugins({ root, onWarn }: DiscoverOptions): str
|
|
|
125
125
|
return discovered
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The routes directory of an installed dashboard shell (`@spree/dashboard`,
|
|
130
|
+
* `@spree/seller-dashboard`), at its real location. The shell's `.` export
|
|
131
|
+
* maps to a file in `src/`, so `routes/` is its sibling directory.
|
|
132
|
+
*
|
|
133
|
+
* The route generator imports these files from the host's `routeTree.gen.ts`.
|
|
134
|
+
* Under pnpm a package is reached through a `node_modules/<name>` link, while
|
|
135
|
+
* its own dependencies sit next to its real location under
|
|
136
|
+
* `node_modules/.pnpm/`. TypeScript does not follow the link for a relative
|
|
137
|
+
* import, so a route file imported through it cannot see the shell's
|
|
138
|
+
* dependencies and the host's type check fails. Plugin routes directories
|
|
139
|
+
* resolve to their real location for the same reason.
|
|
140
|
+
*
|
|
141
|
+
* @param root host project root
|
|
142
|
+
* @param packageName the shell package the host depends on
|
|
143
|
+
*/
|
|
144
|
+
export function resolveShellRoutesDir(root: string, packageName: string): string {
|
|
145
|
+
const require = createRequire(path.join(root, 'package.json'))
|
|
146
|
+
return path.join(path.dirname(fs.realpathSync(require.resolve(packageName))), 'routes')
|
|
147
|
+
}
|
|
148
|
+
|
|
128
149
|
interface HostManifest {
|
|
129
150
|
dependencies?: Record<string, string>
|
|
130
151
|
devDependencies?: Record<string, string>
|