@spree/dashboard-core 0.13.1 → 1.0.0-beta.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.
Files changed (125) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +52 -27
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +383 -127
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +19 -14
  36. package/src/components/table-toolbar.tsx +1357 -440
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +241 -16
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +9 -6
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/table-registry.ts +50 -0
  77. package/src/lib/type-labels.test.ts +68 -0
  78. package/src/lib/type-labels.ts +78 -0
  79. package/src/locales/ar.json +1889 -517
  80. package/src/locales/de.json +1884 -517
  81. package/src/locales/en.json +1886 -519
  82. package/src/locales/fr.json +1884 -517
  83. package/src/locales/pl.json +1913 -517
  84. package/src/locales/zh-CN.json +1883 -517
  85. package/src/pages/package-types.tsx +484 -0
  86. package/src/pages/settings-index.tsx +112 -0
  87. package/src/pages/stock-locations.tsx +674 -0
  88. package/src/plugin.ts +29 -0
  89. package/src/products/add-video-dialog.tsx +100 -0
  90. package/src/products/bulk-variants-dialog.tsx +301 -0
  91. package/src/products/carton-size.test.ts +51 -0
  92. package/src/products/carton-size.ts +20 -0
  93. package/src/products/currency-parts.ts +21 -0
  94. package/src/products/inventory-section.tsx +259 -0
  95. package/src/products/media-edit-sheet.tsx +336 -0
  96. package/src/products/normalize-customs.test.ts +39 -0
  97. package/src/products/normalize-customs.ts +27 -0
  98. package/src/products/normalize-money.test.ts +58 -0
  99. package/src/products/normalize-money.ts +73 -0
  100. package/src/products/normalize-quantity.test.ts +32 -0
  101. package/src/products/normalize-quantity.ts +31 -0
  102. package/src/products/product-bulk-price-editor.tsx +165 -0
  103. package/src/products/product-form-cards.tsx +1167 -0
  104. package/src/products/product-form-mapping.ts +212 -0
  105. package/src/products/product-schema.ts +318 -0
  106. package/src/products/product-types.ts +130 -0
  107. package/src/products/use-product-form-data.ts +273 -0
  108. package/src/products/variant-edit-sheet.tsx +725 -0
  109. package/src/products/variants-matrix.test.ts +154 -0
  110. package/src/products/variants-matrix.ts +244 -0
  111. package/src/products/variants-options-builder.tsx +571 -0
  112. package/src/products/variants-section.tsx +535 -0
  113. package/src/products/video-url.test.ts +71 -0
  114. package/src/products/video-url.ts +83 -0
  115. package/src/providers/auth-provider.tsx +70 -12
  116. package/src/providers/permission-provider.tsx +68 -28
  117. package/src/providers/sticky-header-provider.tsx +134 -0
  118. package/src/providers/store-provider.tsx +36 -3
  119. package/src/providers/tenant-provider.tsx +33 -0
  120. package/src/schemas/package-type.ts +95 -0
  121. package/src/schemas/stock-location.ts +126 -0
  122. package/src/tables/package-types.tsx +74 -0
  123. package/src/tables/stock-locations.tsx +124 -0
  124. package/src/vite/index.ts +40 -7
  125. package/src/components/top-bar.tsx +0 -209
@@ -0,0 +1,86 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { matchDatePreset, resolveDatePreset } from './date-presets'
3
+
4
+ // A fixed instant with a deliberately awkward property: 23:30 UTC on the 15th
5
+ // is already the 16th in Sydney and still the 15th in Los Angeles. Every
6
+ // assertion below turns on which calendar the range is computed against.
7
+ const INSTANT = new Date('2026-03-15T23:30:00Z')
8
+
9
+ beforeEach(() => {
10
+ vi.useFakeTimers()
11
+ vi.setSystemTime(INSTANT)
12
+ })
13
+
14
+ afterEach(() => {
15
+ vi.useRealTimers()
16
+ })
17
+
18
+ describe('resolveDatePreset', () => {
19
+ it('reads "today" on the store\'s calendar, not the viewer\'s', () => {
20
+ // Same instant, three zones, three different answers for "today". This is
21
+ // the whole reason the timezone is threaded through.
22
+ expect(resolveDatePreset('today', 'America/Los_Angeles')).toEqual({
23
+ from: '2026-03-15',
24
+ to: '2026-03-15',
25
+ })
26
+ expect(resolveDatePreset('today', 'UTC')).toEqual({ from: '2026-03-15', to: '2026-03-15' })
27
+ expect(resolveDatePreset('today', 'Australia/Sydney')).toEqual({
28
+ from: '2026-03-16',
29
+ to: '2026-03-16',
30
+ })
31
+ })
32
+
33
+ it('counts "last 7 days" inclusively, so it spans exactly 7 dates', () => {
34
+ expect(resolveDatePreset('last_7_days', 'UTC')).toEqual({
35
+ from: '2026-03-09',
36
+ to: '2026-03-15',
37
+ })
38
+ })
39
+
40
+ it('runs "this month" from the 1st to today', () => {
41
+ expect(resolveDatePreset('this_month', 'UTC')).toEqual({
42
+ from: '2026-03-01',
43
+ to: '2026-03-15',
44
+ })
45
+ })
46
+
47
+ it('ends "last month" the day before this one starts, whatever its length', () => {
48
+ // February 2026 has 28 days — derived, never assumed.
49
+ expect(resolveDatePreset('last_month', 'UTC')).toEqual({
50
+ from: '2026-02-01',
51
+ to: '2026-02-28',
52
+ })
53
+ })
54
+
55
+ it('treats "all" as unbounded on both sides', () => {
56
+ expect(resolveDatePreset('all', 'UTC')).toEqual({ from: null, to: null })
57
+ })
58
+ })
59
+
60
+ describe('matchDatePreset', () => {
61
+ it('recognises a range it produced, so a restored filter re-selects its entry', () => {
62
+ const range = resolveDatePreset('last_30_days', 'UTC')
63
+ expect(matchDatePreset(range, 'UTC')).toBe('last_30_days')
64
+ })
65
+
66
+ it('reads empty bounds as "all"', () => {
67
+ expect(matchDatePreset({ from: null, to: null }, 'UTC')).toBe('all')
68
+ })
69
+
70
+ it('reads a hand-picked range as custom', () => {
71
+ expect(matchDatePreset({ from: '2026-01-02', to: '2026-01-09' }, 'UTC')).toBe('custom')
72
+ })
73
+
74
+ it('reads a stale preset as custom once it no longer matches', () => {
75
+ // Yesterday's "last 7 days", reopened today, is a fixed range that no
76
+ // preset would produce now — calling it "Last 7 days" would misdescribe
77
+ // what the list is actually filtered to.
78
+ const stale = { from: '2026-03-08', to: '2026-03-14' }
79
+ expect(matchDatePreset(stale, 'UTC')).toBe('custom')
80
+ })
81
+
82
+ it('does not match a range computed in another zone', () => {
83
+ const sydney = resolveDatePreset('today', 'Australia/Sydney')
84
+ expect(matchDatePreset(sydney, 'America/Los_Angeles')).toBe('custom')
85
+ })
86
+ })
@@ -0,0 +1,98 @@
1
+ import { format, startOfMonth, subDays, subMonths } from 'date-fns'
2
+ import { toZonedTime } from 'date-fns-tz'
3
+
4
+ /**
5
+ * Relative date ranges offered by the toolbar's quick date filter.
6
+ *
7
+ * `null` bounds mean "unbounded on that side", which is how `all` clears the
8
+ * filter without needing a separate signal.
9
+ */
10
+ export type DatePresetKey =
11
+ | 'all'
12
+ | 'today'
13
+ | 'last_7_days'
14
+ | 'last_30_days'
15
+ | 'this_month'
16
+ | 'last_month'
17
+ | 'custom'
18
+
19
+ /** Presets in menu order. `custom` is handled by the panel, not by this table. */
20
+ export const DATE_PRESET_KEYS: DatePresetKey[] = [
21
+ 'all',
22
+ 'today',
23
+ 'last_7_days',
24
+ 'last_30_days',
25
+ 'this_month',
26
+ 'last_month',
27
+ ]
28
+
29
+ export interface DateRange {
30
+ /** Inclusive lower bound as `yyyy-MM-dd`, or null for unbounded. */
31
+ from: string | null
32
+ /** Inclusive upper bound as `yyyy-MM-dd`, or null for unbounded. */
33
+ to: string | null
34
+ }
35
+
36
+ /**
37
+ * Resolve a preset into concrete `yyyy-MM-dd` bounds.
38
+ *
39
+ * The timezone argument is not decoration: these ranges are relative to *now*,
40
+ * and "today" means today where the merchant trades. Computing them in the
41
+ * browser's zone puts the boundary hours off for anyone checking a store from
42
+ * another country — and it moves depending on who is looking, which makes a
43
+ * shared link mean different things to two people.
44
+ *
45
+ * Bounds are whole dates because that is what the filter stores and what
46
+ * Ransack compares against; the day itself is the smallest unit a merchant
47
+ * reasons about here.
48
+ */
49
+ export function resolveDatePreset(preset: DatePresetKey, timezone: string): DateRange {
50
+ if (preset === 'all' || preset === 'custom') return { from: null, to: null }
51
+
52
+ // "Now" shifted so that reading its *local* fields yields the store's
53
+ // wall-clock date. All arithmetic below then happens on the merchant's
54
+ // calendar, and `format` reads those same local fields back out.
55
+ //
56
+ // `formatInTimeZone` must not be used on these values: it would convert a
57
+ // second time, moving every bound by the offset that was already applied.
58
+ const now = toZonedTime(new Date(), timezone)
59
+ const day = (date: Date) => format(date, 'yyyy-MM-dd')
60
+
61
+ switch (preset) {
62
+ case 'today':
63
+ return { from: day(now), to: day(now) }
64
+ case 'last_7_days':
65
+ return { from: day(subDays(now, 6)), to: day(now) }
66
+ case 'last_30_days':
67
+ return { from: day(subDays(now, 29)), to: day(now) }
68
+ case 'this_month':
69
+ return { from: day(startOfMonth(now)), to: day(now) }
70
+ case 'last_month': {
71
+ const previous = subMonths(now, 1)
72
+ return {
73
+ from: day(startOfMonth(previous)),
74
+ // The day before this month starts — avoids month-length arithmetic.
75
+ to: day(subDays(startOfMonth(now), 1)),
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Recognise which preset a pair of bounds came from, so a filter restored from
83
+ * the URL re-selects its menu entry instead of always reading as "Custom".
84
+ *
85
+ * Matching by value rather than storing the preset key keeps the filter a plain
86
+ * `FilterRule` — the preset is a way of writing a range, not a thing the rest
87
+ * of the pipeline has to know about. A range that no longer matches any preset
88
+ * (yesterday's "last 7 days", reopened today) correctly reads as custom.
89
+ */
90
+ export function matchDatePreset(range: DateRange, timezone: string): DatePresetKey {
91
+ if (!range.from && !range.to) return 'all'
92
+ for (const key of DATE_PRESET_KEYS) {
93
+ if (key === 'all') continue
94
+ const candidate = resolveDatePreset(key, timezone)
95
+ if (candidate.from === range.from && candidate.to === range.to) return key
96
+ }
97
+ return 'custom'
98
+ }
@@ -0,0 +1,15 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * One consignment's editable fields.
5
+ *
6
+ * The carrier is free text: a forwarder's own name is as valid as a
7
+ * registered carrier, and an empty value asks the server to detect one from
8
+ * the number.
9
+ */
10
+ export const deliveryFormSchema = z.object({
11
+ tracking_number: z.string().min(1),
12
+ carrier: z.string(),
13
+ })
14
+
15
+ export type DeliveryFormValues = z.infer<typeof deliveryFormSchema>
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Base for documentation links surfaced in the dashboard.
3
+ *
4
+ * Kept in one place so a docs restructure, a domain change or a locale prefix
5
+ * is a single edit rather than a sweep through every table and settings page.
6
+ */
7
+ export const DOCS_BASE_URL = 'https://spreecommerce.org/docs'
8
+
9
+ /**
10
+ * The other two destinations the account menu offers, kept here for the same
11
+ * reason as the docs base: one edit if a domain moves.
12
+ */
13
+ export const COMMUNITY_URL = 'https://discord.spreecommerce.org'
14
+ export const CONTACT_URL = 'https://spreecommerce.org/contact'
15
+
16
+ /**
17
+ * Resolves a documentation path to a full URL.
18
+ *
19
+ * A bare path is taken as relative to the user guide, which is where the
20
+ * merchant-facing pages live; an absolute URL is returned untouched, so a
21
+ * plugin can point at its own documentation.
22
+ *
23
+ * @param path - `'catalogs'`, `'developer/providers/erp'`, or a full URL
24
+ * @returns the URL to link to
25
+ */
26
+ export function docsUrl(path: string): string {
27
+ if (/^https?:\/\//.test(path)) return path
28
+
29
+ const trimmed = path.replace(/^\/+/, '')
30
+ const section = /^(user|developer)\//.test(trimmed) ? trimmed : `user/${trimmed}`
31
+ return `${DOCS_BASE_URL}/${section}`
32
+ }
@@ -18,15 +18,27 @@ function resolveApiUrl(urlOrPath: string): { url: string; sameOrigin: boolean }
18
18
  * origin get the `Authorization` header and cookies, so the token can't leak
19
19
  * to a third-party host (e.g. a URL echoed back by the server). The filename
20
20
  * comes from `Content-Disposition` when present, else `fallbackName`.
21
+ *
22
+ * `extraHeaders` carries whatever else the panel's API needs to identify the
23
+ * request — the seller panel's `X-Spree-Seller-Id`, without which every Seller
24
+ * API request is refused before the action runs. Prefer
25
+ * `useDownloadHeaders()`, which reads them off the registered client.
21
26
  */
22
27
  export async function downloadFromApi(
23
28
  token: string | null,
24
29
  urlOrPath: string,
25
30
  fallbackName: string,
31
+ extraHeaders: Record<string, string> = {},
26
32
  ): Promise<void> {
27
33
  const { url, sameOrigin } = resolveApiUrl(urlOrPath)
28
34
  const headers: Record<string, string> = {}
29
- if (sameOrigin && token) headers.Authorization = `Bearer ${token}`
35
+ if (sameOrigin && token) {
36
+ headers.Authorization = `Bearer ${token}`
37
+ // Tenant headers ride only to the API origin, for the same reason the
38
+ // token does — a URL echoed back by the server must not carry them
39
+ // anywhere else.
40
+ Object.assign(headers, extraHeaders)
41
+ }
30
42
 
31
43
  const response = await fetch(url, {
32
44
  headers,
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { filtersToRansack } from './filters-to-ransack'
3
+ import type { ColumnDef, FilterRule } from './table-registry'
4
+
5
+ const columns: ColumnDef[] = [
6
+ { key: 'completed_at', label: 'Date', filterType: 'date' },
7
+ { key: 'status', label: 'Status', filterType: 'enum', filterOptions: [] },
8
+ { key: 'tags', label: 'Tags', filterType: 'tags', taggableType: 'Spree::Product' },
9
+ { key: 'sku', label: 'SKU', ransackAttribute: 'master_sku' },
10
+ {
11
+ key: 'companies',
12
+ label: 'Companies',
13
+ filterType: 'resource',
14
+ ransackAttribute: 'with_standing_for_company',
15
+ ransackScope: true,
16
+ filterResource: {
17
+ queryKey: 'companies',
18
+ search: async () => ({ data: [], meta: { count: 0, page: 1, pages: 0 } }),
19
+ hydrate: async () => ({ data: [], meta: { count: 0, page: 1, pages: 0 } }),
20
+ getOptionLabel: () => '',
21
+ },
22
+ },
23
+ ]
24
+
25
+ const rule = (partial: Partial<FilterRule>): FilterRule => ({
26
+ id: 'r1',
27
+ field: 'completed_at',
28
+ operator: 'lteq',
29
+ value: '2026-08-29',
30
+ ...partial,
31
+ })
32
+
33
+ describe('filtersToRansack', () => {
34
+ it('carries a date upper bound to the end of its day', () => {
35
+ // The columns these target are datetimes, so a bare date casts to
36
+ // midnight — "up to the 29th" would drop everything that happened on the
37
+ // 29th, which is usually the very rows the operator is looking for.
38
+ expect(filtersToRansack([rule({})], columns)).toEqual({
39
+ completed_at_lteq: '2026-08-29 23:59:59.999999',
40
+ })
41
+ })
42
+
43
+ it('leaves a date lower bound alone, since midnight already starts the day', () => {
44
+ expect(filtersToRansack([rule({ operator: 'gteq', value: '2026-08-01' })], columns)).toEqual({
45
+ completed_at_gteq: '2026-08-01',
46
+ })
47
+ })
48
+
49
+ it('emits both ends of a range', () => {
50
+ const filters = [
51
+ rule({ id: 'a', operator: 'gteq', value: '2026-08-01' }),
52
+ rule({ id: 'b', operator: 'lteq', value: '2026-08-29' }),
53
+ ]
54
+ expect(filtersToRansack(filters, columns)).toEqual({
55
+ completed_at_gteq: '2026-08-01',
56
+ completed_at_lteq: '2026-08-29 23:59:59.999999',
57
+ })
58
+ })
59
+
60
+ it('does not widen a non-date column that happens to use lteq', () => {
61
+ const columnsWithNumber: ColumnDef[] = [
62
+ ...columns,
63
+ { key: 'total', label: 'Total', filterType: 'number' },
64
+ ]
65
+ const filters = [rule({ field: 'total', operator: 'lteq', value: '100' })]
66
+ expect(filtersToRansack(filters, columnsWithNumber)).toEqual({ total_lteq: '100' })
67
+ })
68
+
69
+ it('splits array operators into a list', () => {
70
+ const filters = [rule({ field: 'status', operator: 'in', value: 'active,draft' })]
71
+ expect(filtersToRansack(filters, columns)).toEqual({ status_in: ['active', 'draft'] })
72
+ })
73
+
74
+ it('drops an array operator with no values rather than filtering by nothing', () => {
75
+ const filters = [rule({ field: 'status', operator: 'in', value: '' })]
76
+ expect(filtersToRansack(filters, columns)).toEqual({})
77
+ })
78
+
79
+ it('targets the join column for tags', () => {
80
+ const filters = [rule({ field: 'tags', operator: 'in', value: 'sale' })]
81
+ expect(filtersToRansack(filters, columns)).toEqual({ tags_name_in: ['sale'] })
82
+ })
83
+
84
+ it('honours an explicit ransack alias', () => {
85
+ const filters = [rule({ field: 'sku', operator: 'i_cont', value: 'ABC' })]
86
+ expect(filtersToRansack(filters, columns)).toEqual({ master_sku_i_cont: 'ABC' })
87
+ })
88
+
89
+ // A scope is invoked by its bare name and takes the value as its argument,
90
+ // so an operator suffix would name a predicate that does not exist and
91
+ // silently filter nothing.
92
+ it('sends a scope key without an operator suffix', () => {
93
+ const filters = [rule({ field: 'companies', operator: 'in', value: 'comp_1,comp_2' })]
94
+
95
+ expect(filtersToRansack(filters, columns)).toEqual({
96
+ with_standing_for_company: ['comp_1', 'comp_2'],
97
+ })
98
+ })
99
+
100
+ // A scope takes its value as an argument and cannot express negation, so
101
+ // emitting the same bare key would run it as an inclusion — the exact
102
+ // opposite of what the merchant picked.
103
+ it('drops a negating operator on a scope column rather than inverting it', () => {
104
+ const filters = [rule({ field: 'companies', operator: 'not_in', value: 'comp_1' })]
105
+
106
+ expect(filtersToRansack(filters, columns)).toEqual({})
107
+ })
108
+
109
+ it('still drops an empty scope filter', () => {
110
+ const filters = [rule({ field: 'companies', operator: 'in', value: '' })]
111
+ expect(filtersToRansack(filters, columns)).toEqual({})
112
+ })
113
+ })
@@ -22,6 +22,8 @@ import { type ColumnDef, type FilterRule, parseFilterIds } from './table-registr
22
22
  // FilterRule[]) stays simple.
23
23
  const ARRAY_OPERATORS = new Set(['in', 'not_in'])
24
24
 
25
+ const NEGATING_OPERATORS = new Set(['not_in', 'not_eq'])
26
+
25
27
  export function filtersToRansack(
26
28
  filters: FilterRule[],
27
29
  columns: ColumnDef[],
@@ -35,10 +37,29 @@ export function filtersToRansack(
35
37
  // ransack alias here when one isn't explicitly set.
36
38
  const fallback = col?.filterType === 'tags' ? 'tags_name' : filter.field
37
39
  const ransackKey = col?.ransackAttribute ?? fallback
38
- const key = `${ransackKey}_${filter.operator}`
40
+ // A scope is invoked by its bare name and takes the value as an argument,
41
+ // so it cannot express negation — emitting the same key for `not_in` would
42
+ // run it as an inclusion.
43
+ if (col?.ransackScope && NEGATING_OPERATORS.has(filter.operator)) continue
44
+
45
+ const key = col?.ransackScope ? ransackKey : `${ransackKey}_${filter.operator}`
39
46
  if (ARRAY_OPERATORS.has(filter.operator)) {
40
47
  const ids = parseFilterIds(filter.value)
41
48
  if (ids.length > 0) out[key] = ids
49
+ } else if (col?.filterType === 'date' && filter.operator === 'lteq') {
50
+ // A date filter's upper bound is a whole day, but the columns it targets
51
+ // are datetimes: Ransack casts `2026-08-29` to midnight, so "up to the
52
+ // 29th" would drop everything that happened during the 29th. Carry the
53
+ // day's last instant instead — to the microsecond, since Rails defaults
54
+ // datetime columns to that precision and a bound of `23:59:59` would
55
+ // drop the final second of the day. The lower bound needs no such
56
+ // treatment: midnight is already the start of its day.
57
+ //
58
+ // Both ends are read in the server's zone, so a store trading far from
59
+ // it sees the same edge-of-day skew its other date filters already have.
60
+ // Fixing that means sending zoned bounds, which is a change to the
61
+ // filter wire format rather than to this conversion.
62
+ out[key] = `${filter.value} 23:59:59.999999`
42
63
  } else {
43
64
  out[key] = filter.value
44
65
  }
@@ -1,6 +1,109 @@
1
+ import type { ValidationErrorDetail } from '@spree/admin-sdk'
1
2
  import { SpreeError } from '@spree/admin-sdk'
3
+ import i18n from 'i18next'
2
4
  import type { FieldValues, Path, UseFormSetError } from 'react-hook-form'
3
5
 
6
+ /**
7
+ * Text for one validation failure, in the admin's own language.
8
+ *
9
+ * Server codes live under `admin.validation.codes`, apart from the client-side
10
+ * keys the forms' own schemas read (`admin.validation.required`, …). The two
11
+ * vocabularies overlap — Rails' `required` is a missing association, the
12
+ * client's is an empty input — so they cannot share a namespace.
13
+ *
14
+ * The Admin API reports the Rails error `code` and the values the validation
15
+ * interpolates, so the copy comes from the dashboard's locale files rather
16
+ * than from a message the server resolved in the store's locale. Keys are
17
+ * tried per-attribute first (`admin.validation.codes.quantity.greater_than`), then
18
+ * generally (`admin.validation.codes.greater_than`); the server's `message` is the
19
+ * fallback, which is what an extension's own error code gets.
20
+ *
21
+ * @param field the attribute the failure is attached to
22
+ * @param entry the API's detail entry, or a plain string from an older payload
23
+ * @returns the message to render
24
+ */
25
+ function resolveDetailMessage(field: string, entry: string | ValidationErrorDetail): string {
26
+ if (typeof entry === 'string') return entry
27
+
28
+ const { code, message, specific, ...interpolation } = entry
29
+ if (!code) return message
30
+
31
+ // Per-attribute first: `admin.validation.codes.url.invalid` is written for this
32
+ // one field and beats both the generic key and the server.
33
+ const attributeKey = `admin.validation.codes.${field}.${code}`
34
+ if (i18n.exists(attributeKey)) return i18n.t(attributeKey, interpolation)
35
+
36
+ // `specific` means the model overrode the code's own wording with something
37
+ // more useful — the webhook URL validation reports `invalid` but answers
38
+ // "must be a valid http or https URL". Translating the code would throw
39
+ // that away. The server decides this, because only it can compare against
40
+ // the code's default in the locale the message was resolved in.
41
+ if (specific) return message
42
+
43
+ const genericKey = `admin.validation.codes.${code}`
44
+ return i18n.exists(genericKey) ? i18n.t(genericKey, interpolation) : message
45
+ }
46
+
47
+ /**
48
+ * Whether {@link resolveDetailMessage} will translate this entry rather than
49
+ * fall back to the server's message.
50
+ *
51
+ * A per-attribute key is written for this one field and wins even over a
52
+ * message the model worded itself; the generic key only applies when the
53
+ * server did not mark the message as its own.
54
+ */
55
+ function hasTranslation(field: string, entry: ValidationErrorDetail): boolean {
56
+ if (i18n.exists(`admin.validation.codes.${field}.${entry.code}`)) return true
57
+ if (entry.specific) return false
58
+ return i18n.exists(`admin.validation.codes.${entry.code}`)
59
+ }
60
+
61
+ /**
62
+ * The failure summary in the admin's own language: "Name can't be blank,
63
+ * Quantity must be greater than 0", assembled from the translated entries and
64
+ * the attribute names the dashboard already publishes.
65
+ *
66
+ * Returns null when any entry has no translation, in which case the server's
67
+ * sentence is left alone rather than half-rebuilt.
68
+ *
69
+ * @param details the API's per-attribute failures
70
+ * @returns the assembled summary, or null to keep the server's
71
+ */
72
+ function translatedSummary(
73
+ details: Record<string, Array<string | ValidationErrorDetail>>,
74
+ ): string | null {
75
+ const sentences: string[] = []
76
+
77
+ for (const [field, entries] of Object.entries(details)) {
78
+ for (const entry of entries ?? []) {
79
+ // A plain string predates the coded shape and carries nothing to
80
+ // translate; a missing code is a message added as bare text.
81
+ if (typeof entry === 'string' || !entry.code) return null
82
+
83
+ // Resolved exactly as the field error is, so the banner and the input
84
+ // beneath it never disagree, and skipped only when that resolution
85
+ // would fall through to the server's own sentence.
86
+ if (!hasTranslation(field, entry)) return null
87
+
88
+ sentences.push(`${attributeLabel(field)} ${resolveDetailMessage(field, entry)}`.trim())
89
+ }
90
+ }
91
+
92
+ return sentences.length > 0 ? sentences.join(', ') : null
93
+ }
94
+
95
+ /**
96
+ * Display name for an attribute, from the same keys the forms label their
97
+ * inputs with. Falls back to the humanized attribute, which is what Rails
98
+ * would have produced anyway.
99
+ */
100
+ function attributeLabel(field: string): string {
101
+ const key = `admin.fields.${field}.label`
102
+ if (i18n.exists(key)) return i18n.t(key)
103
+ if (field === 'base') return ''
104
+ return field.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase())
105
+ }
106
+
4
107
  /**
5
108
  * Map a thrown error from a Spree Admin API mutation onto a react-hook-form
6
109
  * instance. Two things happen on every call:
@@ -43,11 +146,18 @@ import type { FieldValues, Path, UseFormSetError } from 'react-hook-form'
43
146
  * ```json
44
147
  * { "error": {
45
148
  * "code": "validation_error",
46
- * "message": "Code can't be blank, Name can't be blank",
47
- * "details": { "code": ["can't be blank"], "name": ["can't be blank"] }
149
+ * "message": "Code can't be blank, Quantity must be greater than 0",
150
+ * "details": {
151
+ * "code": [{ "code": "blank", "message": "can't be blank" }],
152
+ * "quantity": [{ "code": "greater_than", "message": "must be greater than 0", "count": 0 }]
153
+ * }
48
154
  * } }
49
155
  * ```
50
156
  *
157
+ * Each entry's `code` is translated from the dashboard's own locale files so
158
+ * the admin reads their interface language rather than the store's; `message`
159
+ * is the fallback for codes with no translation.
160
+ *
51
161
  * `details` keys are AR attribute names. `<form-field>.base` is AR's
52
162
  * record-level errors collection on a nested association — we treat both
53
163
  * the bare `base` key and any `*.base` key as record-level and route them
@@ -61,11 +171,13 @@ export function mapSpreeErrorsToForm<TFieldValues extends FieldValues>(
61
171
 
62
172
  const { details, message } = error
63
173
 
64
- // Always seed the root error with the server's top-level message. Even
65
- // when details exist, the top-level message is the readable summary —
66
- // and it's the only place nested/association errors will reliably show.
67
- if (message) {
68
- setError('root' as Path<TFieldValues>, { type: 'server', message })
174
+ // The summary banner is the line a merchant actually reads, so it follows
175
+ // their interface language too. It is rebuilt from the translated entries
176
+ // when every one of them translated; a single untranslated code means the
177
+ // server's own sentence is still the more complete summary, and it stays.
178
+ const summary = (details && translatedSummary(details)) || message
179
+ if (summary) {
180
+ setError('root' as Path<TFieldValues>, { type: 'server', message: summary })
69
181
  }
70
182
 
71
183
  if (!details) return true
@@ -79,13 +191,13 @@ export function mapSpreeErrorsToForm<TFieldValues extends FieldValues>(
79
191
  // nested or associative falls back to the root summary that we already
80
192
  // set above. Silently dropping errors onto unrendered field paths is a
81
193
  // worse failure mode than redundant root messages.
82
- for (const [field, messages] of Object.entries(details)) {
83
- if (!messages?.length) continue
194
+ for (const [field, entries] of Object.entries(details)) {
195
+ if (!entries?.length) continue
84
196
  if (!isRenderableFieldKey(field)) continue
85
197
 
86
198
  setError(field as Path<TFieldValues>, {
87
199
  type: 'server',
88
- message: messages.join(', '),
200
+ message: entries.map((entry) => resolveDetailMessage(field, entry)).join(', '),
89
201
  })
90
202
  }
91
203
 
@@ -28,3 +28,79 @@ export function emptyToUndefined(value: unknown): unknown {
28
28
  if (value === '' || value === null) return undefined
29
29
  return value
30
30
  }
31
+
32
+ /**
33
+ * The address columns a stock location carries, in either panel's SDK shape.
34
+ * Structural rather than either `StockLocation` type so both the admin and
35
+ * seller panels can map their own rows through the helpers below.
36
+ */
37
+ export interface StockLocationAddressFields {
38
+ company?: string | null
39
+ address1?: string | null
40
+ address2?: string | null
41
+ city?: string | null
42
+ zipcode?: string | null
43
+ state_code?: string | null
44
+ state_text?: string | null
45
+ country_code?: string | null
46
+ country_name?: string | null
47
+ phone?: string | null
48
+ }
49
+
50
+ /**
51
+ * The three shapes a stock location's address sits between. A location stores
52
+ * `zipcode` where an address says `postal_code`, and carries no personal name
53
+ * — mapping the fields explicitly keeps that difference in one place instead
54
+ * of leaking a cast into every call.
55
+ */
56
+ export function stockLocationToAddressBlock(location: StockLocationAddressFields) {
57
+ return {
58
+ company: location.company,
59
+ address1: location.address1,
60
+ address2: location.address2,
61
+ city: location.city,
62
+ state_text: location.state_text,
63
+ postal_code: location.zipcode,
64
+ country_code: location.country_code,
65
+ country_name: location.country_name,
66
+ phone: location.phone,
67
+ }
68
+ }
69
+
70
+ export function stockLocationToEditableAddress(
71
+ location: StockLocationAddressFields & { id: string },
72
+ ) {
73
+ return {
74
+ id: location.id,
75
+ company: location.company,
76
+ address1: location.address1,
77
+ address2: location.address2,
78
+ city: location.city,
79
+ postal_code: location.zipcode,
80
+ country_code: location.country_code,
81
+ state_code: location.state_code,
82
+ phone: location.phone,
83
+ }
84
+ }
85
+
86
+ export function editableAddressToStockLocationParams(values: {
87
+ company?: string | null
88
+ address1?: string | null
89
+ address2?: string | null
90
+ city?: string | null
91
+ postal_code?: string | null
92
+ country_code?: string | null
93
+ state_code?: string | null
94
+ phone?: string | null
95
+ }) {
96
+ return {
97
+ company: values.company,
98
+ address1: values.address1,
99
+ address2: values.address2,
100
+ city: values.city,
101
+ zipcode: values.postal_code,
102
+ country_code: values.country_code,
103
+ state_code: values.state_code,
104
+ phone: values.phone,
105
+ }
106
+ }