@spree/dashboard-core 1.0.0-beta.8 → 1.0.0-rc.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.
@@ -23,7 +23,7 @@ function discoverDashboardPluginManifests({ root, onWarn }, names) {
23
23
  const routes = pkg.spree?.dashboard?.routes;
24
24
  manifests.push({
25
25
  name,
26
- routesDir: routes ? path.resolve(path.dirname(manifestPath), routes) : void 0
26
+ routesDir: routes ? linkedPackagePath(root, name, path.resolve(path.dirname(manifestPath), routes)) : void 0
27
27
  });
28
28
  }
29
29
  return manifests;
@@ -63,6 +63,18 @@ function discoverDashboardPlugins({ root, onWarn }) {
63
63
  }
64
64
  return discovered;
65
65
  }
66
+ function linkedPackagePath(root, packageName, resolvedPath) {
67
+ const realPath = fs.realpathSync(resolvedPath);
68
+ for (let dir = root; ; dir = path.dirname(dir)) {
69
+ const link = path.join(dir, "node_modules", packageName);
70
+ if (fs.existsSync(path.join(link, "package.json"))) {
71
+ const inside = path.relative(fs.realpathSync(link), realPath);
72
+ if (inside.startsWith("..") || path.isAbsolute(inside)) return resolvedPath;
73
+ return path.join(link, inside);
74
+ }
75
+ if (path.dirname(dir) === dir) return resolvedPath;
76
+ }
77
+ }
66
78
  function resolveManifest(pkg, require2) {
67
79
  try {
68
80
  return require2.resolve(`${pkg}/package.json`);
@@ -85,5 +97,6 @@ function resolveManifest(pkg, require2) {
85
97
  }
86
98
  export {
87
99
  discoverDashboardPluginManifests,
88
- discoverDashboardPlugins
100
+ discoverDashboardPlugins,
101
+ linkedPackagePath
89
102
  };
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-beta.8",
4
+ "version": "1.0.0-rc.1",
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-beta.3",
85
- "@spree/dashboard-ui": "^1.0.0-beta.8"
84
+ "@spree/admin-sdk": "^1.0.0-rc.1",
85
+ "@spree/dashboard-ui": "^1.0.0-rc.1"
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)) throw err
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>
@@ -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 class name (e.g. "Spree::Product") */
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="Spree::Product">
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 Hash<threshold, value> that the default
174
- // hash-as-text input would render as `[object Object]`. Render a row
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 `Hash<threshold, value>` — orders
369
- * at or above `threshold` get `value` (% or $ depending on the
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 hash on every change would drop
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 hash value.
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: Record<string, number>) => void
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
- const out: Record<string, number> = {}
396
- for (const row of next) {
397
- const t = row.threshold.trim()
398
- if (!t) continue
399
- const parsed = Number(row.value)
400
- out[t] = Number.isFinite(parsed) ? parsed : 0
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
- * Normalize whatever shape the server sent — typically `Hash<number,
499
- * number>` but JSON serialization stringifies the keys, so we may
500
- * receive `{ "100": 10 }` or `{ 100: 10 }`. Either form converts to
501
- * `TierRowState[]` for editor state.
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' || Array.isArray(value)) return []
505
- return Object.entries(value as Record<string, unknown>)
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
- staleTime: 30_000,
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 h-full shrink-0 overflow-hidden bg-muted text-sidebar-foreground transition-[width,border-color] duration-200 ease-out lg:block',
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 p-1.5">{header}</div>
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: 'Spree::Product' },
8
+ { key: 'tags', label: 'Tags', filterType: 'tags', taggableType: 'product' },
9
9
  { key: 'sku', label: 'SKU', ransackAttribute: 'master_sku' },
10
10
  {
11
11
  key: 'companies',
@@ -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
- sentences.push(`${attributeLabel(field)} ${resolveDetailMessage(field, entry)}`.trim())
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
- setError(field as Path<TFieldValues>, {
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
  })
@@ -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)
@@ -1,90 +1,89 @@
1
1
  /**
2
- * CanCanCan subject class names — use these constants instead of raw strings
3
- * to avoid typos in permission checks.
2
+ * Permission subjects — use these constants instead of raw strings to avoid
3
+ * typos in permission checks.
4
4
  *
5
- * Matches the Ruby class names serialized by the /api/v3/admin/me endpoint.
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: 'Spree::Product',
10
- ProductType: 'Spree::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: 'Spree::Media',
14
- Variant: 'Spree::Variant',
15
- Order: 'Spree::Order',
16
- SavedReport: 'Spree::SavedReport',
17
- ReportExport: 'Spree::Exports::Report',
18
- Customer: 'Spree::Customer',
19
- CustomerGroup: 'Spree::CustomerGroup',
20
- AdminUser: 'Spree::AdminUser',
21
- ApiKey: 'Spree::ApiKey',
22
- AllowedOrigin: 'Spree::AllowedOrigin',
23
- Store: 'Spree::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: 'Spree::PackageType',
27
- Channel: 'Spree::Channel',
28
- OrderRoutingRule: 'Spree::OrderRoutingRule',
29
- // Categories are Spree::Category < Spree::Taxon; abilities are published under
30
- // the Spree::Taxon subject, so the value stays 'Spree::Taxon' while the key
31
- // reads as the user-facing resource name.
32
- Category: 'Spree::Taxon',
33
- Collection: 'Spree::Collection',
34
- OptionType: 'Spree::OptionType',
35
- OptionValue: 'Spree::OptionValue',
36
- Policy: 'Spree::Policy',
37
- TaxCategory: 'Spree::TaxCategory',
38
- TaxRate: 'Spree::TaxRate',
39
- TaxIdentifier: 'Spree::TaxIdentifier',
40
- TaxExemptionCertificate: 'Spree::TaxExemptionCertificate',
41
- Company: 'Spree::Company',
42
- CompanyAddress: 'Spree::CompanyAddress',
43
- CompanyMembership: 'Spree::CompanyMembership',
44
- CompanyInvitation: 'Spree::CompanyInvitation',
45
- Catalog: 'Spree::Catalog',
46
- ReturnReason: 'Spree::ReturnReason',
47
- ClaimReason: 'Spree::ClaimReason',
48
- RefundReason: 'Spree::RefundReason',
49
- OrderCancellationReason: 'Spree::OrderCancellationReason',
50
- CustomFieldDefinition: 'Spree::CustomFieldDefinition',
51
- Integration: 'Spree::Integration',
52
- PaymentMethod: 'Spree::PaymentMethod',
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: 'Spree::ShippingMethod',
58
- StockLocation: 'Spree::StockLocation',
59
- StockLevel: 'Spree::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: 'Spree::StockItem',
62
- StockTransfer: 'Spree::StockTransfer',
63
- StockTransferItem: 'Spree::StockTransferItem',
64
- Supplier: 'Spree::Supplier',
65
- PurchaseOrder: 'Spree::PurchaseOrder',
66
- PurchaseOrderItem: 'Spree::PurchaseOrderItem',
67
- PriceList: 'Spree::PriceList',
68
- PriceRule: 'Spree::PriceRule',
69
- Promotion: 'Spree::Promotion',
70
- PromotionAction: 'Spree::PromotionAction',
71
- PromotionRule: 'Spree::PromotionRule',
72
- GiftCard: 'Spree::GiftCard',
73
- GiftCardBatch: 'Spree::GiftCardBatch',
74
- StoreCredit: 'Spree::StoreCredit',
75
- StoreCreditEvent: 'Spree::StoreCreditEvent',
76
- Role: 'Spree::Role',
77
- Invitation: 'Spree::Invitation',
78
- Market: 'Spree::Market',
79
- WebhookEndpoint: 'Spree::WebhookEndpoint',
80
- WebhookDelivery: 'Spree::WebhookDelivery',
81
- Wishlist: 'Spree::Wishlist',
82
- Seller: 'Spree::Seller',
83
- CommissionRate: 'Spree::CommissionRate',
84
- CommissionRule: 'Spree::CommissionRule',
85
- CommissionLine: 'Spree::CommissionLine',
86
- SellerPayout: 'Spree::SellerPayout',
87
- SellerTransfer: 'Spree::SellerTransfer',
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
- * Models that `TagCombobox` can target for tag autocomplete. The three
88
- * first-class taggables (Product, Customer, Order) get autocomplete hints, but
89
- * the union is open — apps can pass any Ruby class string the backend's
90
- * `TagsController#allowed_taggable_types` accepts (override that method
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 = 'Spree::Product' | 'Spree::Customer' | 'Spree::Order' | (string & {})
92
+ export type TaggableType = 'product' | 'customer' | 'order' | (string & {})
95
93
 
96
94
  /**
97
95
  * Column definition. Discriminated union on `filterType`:
@@ -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": {
@@ -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. Bestandsartikel an diesem Standort werden nicht gelöscht.",
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": {
@@ -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 are not deleted.",
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": {
@@ -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 no se borran.",
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.",
@@ -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 articles en stock à cet emplacement ne sont pas supprimés.",
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": {
@@ -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. Produkty na stanie w tej lokalizacji nie zostaną usunięte.",
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": {
@@ -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 { type Control, Controller, type UseFormReturn, useWatch } from 'react-hook-form'
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 categoryIds = selectedProductType?.category_ids
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="Spree::Product"
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 { variantToFormValues, variantToWirePayload } from './product-form-mapping'
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
- await queryClient.invalidateQueries({ queryKey: [PERMISSIONS_RESOURCE, tenantId] })
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(
@@ -71,7 +71,9 @@ export function discoverDashboardPluginManifests(
71
71
  const routes = pkg.spree?.dashboard?.routes
72
72
  manifests.push({
73
73
  name,
74
- routesDir: routes ? path.resolve(path.dirname(manifestPath), routes) : undefined,
74
+ routesDir: routes
75
+ ? linkedPackagePath(root, name, path.resolve(path.dirname(manifestPath), routes))
76
+ : undefined,
75
77
  })
76
78
  }
77
79
  return manifests
@@ -125,6 +127,37 @@ export function discoverDashboardPlugins({ root, onWarn }: DiscoverOptions): str
125
127
  return discovered
126
128
  }
127
129
 
130
+ /**
131
+ * Re-expresses a path inside an installed package through the package's
132
+ * `node_modules/<name>` link, rather than the real location Node resolves to.
133
+ *
134
+ * Under pnpm the real location is `node_modules/.pnpm/<name>@<version>_<hash>/…`,
135
+ * which changes with every install. The route generator writes these paths
136
+ * into the host's committed `routeTree.gen.ts`, so a real location made the
137
+ * file differ on a new project's first dev start and after every upgrade —
138
+ * and each rewrite reloaded the open page. The link stays put across installs
139
+ * and is the same inside the monorepo, so the file only changes when the
140
+ * routes do.
141
+ *
142
+ * @param root host project root
143
+ * @param packageName the package the path belongs to
144
+ * @param resolvedPath a path inside that package, as Node resolved it
145
+ * @returns the same path through the link, or `resolvedPath` when no link
146
+ * leads to it (the package resolving itself, an unusual layout)
147
+ */
148
+ export function linkedPackagePath(root: string, packageName: string, resolvedPath: string): string {
149
+ const realPath = fs.realpathSync(resolvedPath)
150
+ for (let dir = root; ; dir = path.dirname(dir)) {
151
+ const link = path.join(dir, 'node_modules', packageName)
152
+ if (fs.existsSync(path.join(link, 'package.json'))) {
153
+ const inside = path.relative(fs.realpathSync(link), realPath)
154
+ if (inside.startsWith('..') || path.isAbsolute(inside)) return resolvedPath
155
+ return path.join(link, inside)
156
+ }
157
+ if (path.dirname(dir) === dir) return resolvedPath
158
+ }
159
+ }
160
+
128
161
  interface HostManifest {
129
162
  dependencies?: Record<string, string>
130
163
  devDependencies?: Record<string, string>