@spree/dashboard-core 0.13.1 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,213 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ fulfilledQuantities,
4
+ fulfillmentItemRows,
5
+ type GroupableFulfillment,
6
+ type GroupableLineItem,
7
+ unfulfilledItemRows,
8
+ } from './fulfillment-items'
9
+
10
+ function lineItem(overrides: Partial<GroupableLineItem> = {}): GroupableLineItem {
11
+ return {
12
+ id: 'li_1',
13
+ name: 'Shirt',
14
+ options_text: 'Blue / M',
15
+ quantity: 2,
16
+ thumbnail_url: 'https://example.test/shirt.jpg',
17
+ display_price: '$10.00',
18
+ ...overrides,
19
+ }
20
+ }
21
+
22
+ function fulfillment(overrides: Partial<GroupableFulfillment> = {}): GroupableFulfillment {
23
+ return {
24
+ status: 'pending',
25
+ fulfillment_items: [],
26
+ ...overrides,
27
+ }
28
+ }
29
+
30
+ describe('fulfillmentItemRows', () => {
31
+ it('joins a fulfillment item to its line item for the image and price', () => {
32
+ const rows = fulfillmentItemRows(
33
+ fulfillment({
34
+ fulfillment_items: [{ line_item_id: 'li_1', variant_id: 'variant_1', quantity: 1 }],
35
+ }),
36
+ [lineItem()],
37
+ )
38
+
39
+ expect(rows).toEqual([
40
+ {
41
+ key: 'li_1',
42
+ lineItem: expect.objectContaining({ id: 'li_1' }),
43
+ name: 'Shirt',
44
+ optionsText: 'Blue / M',
45
+ thumbnailUrl: 'https://example.test/shirt.jpg',
46
+ displayPrice: '$10.00',
47
+ quantity: 1,
48
+ },
49
+ ])
50
+ })
51
+
52
+ it('merges several fulfillment items descending from one line item', () => {
53
+ const rows = fulfillmentItemRows(
54
+ fulfillment({
55
+ fulfillment_items: [
56
+ { line_item_id: 'li_1', quantity: 1 },
57
+ { line_item_id: 'li_1', quantity: 2 },
58
+ ],
59
+ }),
60
+ [lineItem({ quantity: 3 })],
61
+ )
62
+
63
+ expect(rows).toHaveLength(1)
64
+ expect(rows[0].quantity).toBe(3)
65
+ })
66
+
67
+ it('falls back to the fulfillment item copy when the line item is missing', () => {
68
+ const rows = fulfillmentItemRows(
69
+ fulfillment({
70
+ fulfillment_items: [
71
+ { line_item_id: 'li_gone', name: 'Removed item', options_text: 'Red', quantity: 1 },
72
+ ],
73
+ }),
74
+ [],
75
+ )
76
+
77
+ expect(rows[0]).toMatchObject({
78
+ name: 'Removed item',
79
+ optionsText: 'Red',
80
+ thumbnailUrl: null,
81
+ displayPrice: null,
82
+ })
83
+ })
84
+
85
+ it('keys an item with no line item by its variant so it still renders', () => {
86
+ const rows = fulfillmentItemRows(
87
+ fulfillment({
88
+ fulfillment_items: [{ variant_id: 'variant_9', name: 'Loose unit', quantity: 1 }],
89
+ }),
90
+ [],
91
+ )
92
+
93
+ expect(rows).toHaveLength(1)
94
+ expect(rows[0].key).toBe('variant_9')
95
+ })
96
+ })
97
+
98
+ describe('unfulfilledItemRows', () => {
99
+ it('reports the quantity no fulfillment claims', () => {
100
+ const rows = unfulfilledItemRows(
101
+ [lineItem({ quantity: 5 })],
102
+ [fulfillment({ fulfillment_items: [{ line_item_id: 'li_1', quantity: 2 }] })],
103
+ )
104
+
105
+ expect(rows).toHaveLength(1)
106
+ expect(rows[0].quantity).toBe(3)
107
+ })
108
+
109
+ it('sums claims across several fulfillments', () => {
110
+ const rows = unfulfilledItemRows(
111
+ [lineItem({ quantity: 4 })],
112
+ [
113
+ fulfillment({ fulfillment_items: [{ line_item_id: 'li_1', quantity: 1 }] }),
114
+ fulfillment({
115
+ status: 'shipped',
116
+ fulfillment_items: [{ line_item_id: 'li_1', quantity: 2 }],
117
+ }),
118
+ ],
119
+ )
120
+
121
+ expect(rows[0].quantity).toBe(1)
122
+ })
123
+
124
+ it('returns nothing when the fulfillments account for every unit', () => {
125
+ const rows = unfulfilledItemRows(
126
+ [lineItem({ quantity: 2 })],
127
+ [fulfillment({ fulfillment_items: [{ line_item_id: 'li_1', quantity: 2 }] })],
128
+ )
129
+
130
+ expect(rows).toEqual([])
131
+ })
132
+
133
+ it('never reports a negative remainder', () => {
134
+ const rows = unfulfilledItemRows(
135
+ [lineItem({ quantity: 1 })],
136
+ [fulfillment({ fulfillment_items: [{ line_item_id: 'li_1', quantity: 3 }] })],
137
+ )
138
+
139
+ expect(rows).toEqual([])
140
+ })
141
+
142
+ it('ignores fulfillment items that reference no line item', () => {
143
+ const rows = unfulfilledItemRows(
144
+ [lineItem({ quantity: 2 })],
145
+ [fulfillment({ fulfillment_items: [{ variant_id: 'variant_1', quantity: 2 }] })],
146
+ )
147
+
148
+ expect(rows[0].quantity).toBe(2)
149
+ })
150
+ })
151
+
152
+ describe('fulfilledQuantities', () => {
153
+ it('sums units per line item across fulfilled fulfillments', () => {
154
+ const quantities = fulfilledQuantities([
155
+ fulfillment({
156
+ status: 'fulfilled',
157
+ fulfillment_items: [
158
+ { line_item_id: 'li_1', variant_id: 'variant_1', quantity: 1 },
159
+ { line_item_id: 'li_2', variant_id: 'variant_2', quantity: 2 },
160
+ ],
161
+ }),
162
+ fulfillment({
163
+ status: 'fulfilled',
164
+ fulfillment_items: [{ line_item_id: 'li_1', variant_id: 'variant_1', quantity: 2 }],
165
+ }),
166
+ ])
167
+
168
+ expect(quantities.get('li_1')).toBe(3)
169
+ expect(quantities.get('li_2')).toBe(2)
170
+ })
171
+
172
+ // Canceled fulfillments restock; pending ones have not shipped. Neither
173
+ // pins an edit.
174
+ it('ignores fulfillments that have not shipped', () => {
175
+ const quantities = fulfilledQuantities([
176
+ fulfillment({
177
+ status: 'pending',
178
+ fulfillment_items: [{ line_item_id: 'li_1', variant_id: 'variant_1', quantity: 2 }],
179
+ }),
180
+ fulfillment({
181
+ status: 'canceled',
182
+ fulfillment_items: [{ line_item_id: 'li_1', variant_id: 'variant_1', quantity: 1 }],
183
+ }),
184
+ ])
185
+
186
+ expect(quantities.size).toBe(0)
187
+ })
188
+
189
+ // Delivered is downstream of fulfilled — those units are just as gone, and
190
+ // treating them as available would let an edit remove goods the customer
191
+ // already has.
192
+ it('counts units in delivered fulfillments', () => {
193
+ const quantities = fulfilledQuantities([
194
+ fulfillment({
195
+ status: 'delivered',
196
+ fulfillment_items: [{ line_item_id: 'li_1', variant_id: 'variant_1', quantity: 2 }],
197
+ }),
198
+ ])
199
+
200
+ expect(quantities.get('li_1')).toBe(2)
201
+ })
202
+
203
+ it('skips fulfillment items that reference no line item', () => {
204
+ const quantities = fulfilledQuantities([
205
+ fulfillment({
206
+ status: 'fulfilled',
207
+ fulfillment_items: [{ line_item_id: null, variant_id: 'variant_1', quantity: 2 }],
208
+ }),
209
+ ])
210
+
211
+ expect(quantities.size).toBe(0)
212
+ })
213
+ })
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Which line items sit in which fulfillment, and which units nobody has
3
+ * claimed yet. The order payload describes the two halves separately —
4
+ * `order.items` is the full basket, each fulfillment carries its own
5
+ * `fulfillment_items` — so the card has to join them back together to say
6
+ * what is being shipped where.
7
+ */
8
+
9
+ /** The line-item fields the grouping reads. */
10
+ export interface GroupableLineItem {
11
+ id: string
12
+ name: string
13
+ options_text?: string | null
14
+ quantity: number
15
+ thumbnail_url?: string | null
16
+ display_price?: string | null
17
+ display_total?: string | null
18
+ }
19
+
20
+ /** The fulfillment-item fields the grouping reads. */
21
+ export interface GroupableFulfillmentItem {
22
+ line_item_id?: string | null
23
+ variant_id?: string | null
24
+ name?: string | null
25
+ options_text?: string | null
26
+ quantity: number
27
+ }
28
+
29
+ /** The fulfillment fields the grouping reads. */
30
+ export interface GroupableFulfillment {
31
+ status: string
32
+ fulfillment_items?: GroupableFulfillmentItem[] | null
33
+ }
34
+
35
+ /**
36
+ * One row in a fulfillment's (or the unfulfilled group's) item list: the
37
+ * line item it descends from, plus how many of its units this group holds.
38
+ */
39
+ export interface FulfillmentItemRow {
40
+ /** Stable within one group — a line item appears at most once per group. */
41
+ key: string
42
+ lineItem: GroupableLineItem | null
43
+ /** Falls back to the fulfillment item's own copy when the line item is gone. */
44
+ name: string
45
+ optionsText: string | null
46
+ thumbnailUrl: string | null
47
+ displayPrice: string | null
48
+ quantity: number
49
+ }
50
+
51
+ /**
52
+ * Sums, per line item, how many units every fulfillment claims. Fulfillment
53
+ * items that reference no line item are skipped — there is nothing to join
54
+ * them to, and counting them would understate the remainder.
55
+ */
56
+ function claimedQuantities(fulfillments: GroupableFulfillment[]): Map<string, number> {
57
+ const claimed = new Map<string, number>()
58
+
59
+ for (const fulfillment of fulfillments) {
60
+ for (const item of fulfillment.fulfillment_items ?? []) {
61
+ if (!item.line_item_id) continue
62
+ claimed.set(item.line_item_id, (claimed.get(item.line_item_id) ?? 0) + item.quantity)
63
+ }
64
+ }
65
+
66
+ return claimed
67
+ }
68
+
69
+ /**
70
+ * Sums, per line item, how many units have left the merchant's hands. Those
71
+ * units are gone, so an edit can neither remove the row nor push its quantity
72
+ * below them. Delivered counts too — it is downstream of fulfilled. Canceled
73
+ * fulfillments restock their units and do not count.
74
+ */
75
+ /**
76
+ * Fulfillment statuses meaning the goods have left the merchant's hands.
77
+ * The order-level rollup uses the same two words for "everything has gone
78
+ * out", so the pages gating order editing share this list.
79
+ */
80
+ export const GONE_STATUSES = ['fulfilled', 'delivered']
81
+
82
+ export function fulfilledQuantities(fulfillments: GroupableFulfillment[]): Map<string, number> {
83
+ const fulfilled = new Map<string, number>()
84
+
85
+ for (const fulfillment of fulfillments) {
86
+ if (!GONE_STATUSES.includes(fulfillment.status)) continue
87
+
88
+ for (const item of fulfillment.fulfillment_items ?? []) {
89
+ if (!item.line_item_id) continue
90
+ fulfilled.set(item.line_item_id, (fulfilled.get(item.line_item_id) ?? 0) + item.quantity)
91
+ }
92
+ }
93
+
94
+ return fulfilled
95
+ }
96
+
97
+ /** Builds a row, preferring the line item's own copy over the fulfillment item's. */
98
+ function buildRow(
99
+ key: string,
100
+ lineItem: GroupableLineItem | undefined,
101
+ fallback: Pick<GroupableFulfillmentItem, 'name' | 'options_text'> | null,
102
+ quantity: number,
103
+ ): FulfillmentItemRow {
104
+ return {
105
+ key,
106
+ lineItem: lineItem ?? null,
107
+ name: lineItem?.name ?? fallback?.name ?? '',
108
+ optionsText: lineItem?.options_text ?? fallback?.options_text ?? null,
109
+ thumbnailUrl: lineItem?.thumbnail_url ?? null,
110
+ displayPrice: lineItem?.display_price ?? null,
111
+ quantity,
112
+ }
113
+ }
114
+
115
+ /**
116
+ * The items one fulfillment holds, joined to their line items for the image
117
+ * and the price the fulfillment item does not carry. Several fulfillment
118
+ * items can descend from the same line item (units split by on-hand versus
119
+ * backordered status), so they are merged into a single row.
120
+ */
121
+ export function fulfillmentItemRows(
122
+ fulfillment: GroupableFulfillment,
123
+ lineItems: GroupableLineItem[],
124
+ ): FulfillmentItemRow[] {
125
+ const byId = new Map(lineItems.map((lineItem) => [lineItem.id, lineItem]))
126
+ const rows = new Map<string, FulfillmentItemRow>()
127
+
128
+ for (const item of fulfillment.fulfillment_items ?? []) {
129
+ // A fulfillment item with no line item still ships; key it by variant so
130
+ // it renders rather than vanishing.
131
+ const key = item.line_item_id ?? item.variant_id
132
+ if (!key) continue
133
+
134
+ const existing = rows.get(key)
135
+ if (existing) {
136
+ existing.quantity += item.quantity
137
+ continue
138
+ }
139
+
140
+ rows.set(
141
+ key,
142
+ buildRow(
143
+ key,
144
+ item.line_item_id ? byId.get(item.line_item_id) : undefined,
145
+ item,
146
+ item.quantity,
147
+ ),
148
+ )
149
+ }
150
+
151
+ return [...rows.values()]
152
+ }
153
+
154
+ /**
155
+ * Line-item units no fulfillment has claimed: the ordered quantity minus what
156
+ * every fulfillment holds. Only positive remainders are returned, so an order
157
+ * whose fulfillments account for everything yields an empty list.
158
+ */
159
+ export function unfulfilledItemRows(
160
+ lineItems: GroupableLineItem[],
161
+ fulfillments: GroupableFulfillment[],
162
+ ): FulfillmentItemRow[] {
163
+ const claimed = claimedQuantities(fulfillments)
164
+
165
+ return lineItems.flatMap((lineItem) => {
166
+ const remainder = lineItem.quantity - (claimed.get(lineItem.id) ?? 0)
167
+ if (remainder <= 0) return []
168
+
169
+ return [buildRow(lineItem.id, lineItem, null, remainder)]
170
+ })
171
+ }
package/src/lib/i18n.ts CHANGED
@@ -116,8 +116,9 @@ export function reconcileStoreDefaultLocale(
116
116
  }
117
117
 
118
118
  // All non-English core bundles, imported EAGERLY so the active language has its
119
- // resources synchronously available (registered just after init() below) —
120
- // no flash, no async race with module-load `i18n.t(...)` calls.
119
+ // resources synchronously available when init() runs below — no flash, no async
120
+ // race with module-load `i18n.t(...)` calls, and the stored language resolves
121
+ // to itself rather than falling back.
121
122
  const coreLocales = import.meta.glob<{ default: Record<string, unknown> }>(
122
123
  ['../locales/*.json', '!../locales/en.json'],
123
124
  { eager: true },
@@ -160,10 +161,23 @@ export function switchLocale(code: string): void {
160
161
  // after this side-effect import has settled. The `deep` + `overwrite` flags
161
162
  // merge the extension into the base namespace without dropping framework keys.
162
163
  //
163
- // English ships eagerly; `lng` is read from localStorage so a previously
164
- // chosen language is the initial language on first paint (no flash).
164
+ // `lng` is read from localStorage so a previously chosen language is the
165
+ // initial language on first paint (no flash).
165
166
  i18n.use(initReactI18next).init({
166
- resources: { en: { translation: en } },
167
+ // Every bundle is registered up front. Passing `lng` with only English
168
+ // loaded would resolve the language to the fallback and leave
169
+ // `resolvedLanguage` stuck on 'en' — which reads as "the stored language
170
+ // never took effect" and sends the store-default reconciler into a
171
+ // reload loop.
172
+ resources: {
173
+ en: { translation: en },
174
+ ...Object.fromEntries(
175
+ Object.entries(coreLocales).map(([path, mod]) => [
176
+ path.replace('../locales/', '').replace('.json', ''),
177
+ { translation: mod.default },
178
+ ]),
179
+ ),
180
+ },
167
181
  lng: readStoredLocale(),
168
182
  fallbackLng: 'en',
169
183
  interpolation: { escapeValue: false },
@@ -176,12 +190,6 @@ i18n.use(initReactI18next).init({
176
190
  : undefined,
177
191
  })
178
192
 
179
- // Register the eager non-English core bundles now that i18next is initialized.
180
- for (const [path, mod] of Object.entries(coreLocales)) {
181
- const code = path.replace('../locales/', '').replace('.json', '')
182
- i18n.addResourceBundle(code, 'translation', mod.default, true, true)
183
- }
184
-
185
193
  // Reflect the active language on the root <html> element so the document
186
194
  // advertises the right `lang` (a11y, spellcheck, font selection) and `dir`
187
195
  // (RTL for Arabic/Hebrew/etc.). `i18n.dir()` consults i18next's built-in RTL
@@ -0,0 +1,25 @@
1
+ import i18n from 'i18next'
2
+
3
+ /**
4
+ * The API's type shorthand (`products`), which keys the per-type translations
5
+ * (`admin.imports.types.<key>`).
6
+ *
7
+ * Shorthand values pass through unchanged; the demodulizing is a fallback for
8
+ * `Spree::Imports::Products`-style values from an older server or a payload
9
+ * cached before the shorthand landed.
10
+ */
11
+ export function importTypeKey(type: string | null): string {
12
+ return (
13
+ (type ?? '')
14
+ .split('::')
15
+ .pop()
16
+ ?.replace(/([a-z])([A-Z])/g, '$1_$2')
17
+ .toLowerCase() ?? ''
18
+ )
19
+ }
20
+
21
+ /** Translated display name for an import type (`admin.imports.types.<key>`). */
22
+ export function importTypeLabel(type: string | null): string {
23
+ const key = importTypeKey(type)
24
+ return key ? i18n.t(`admin.imports.types.${key}`, { defaultValue: key }) : ''
25
+ }
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * What a label bought outside Spree needs recording against a parcel.
5
+ *
6
+ * The carrier is free text: a forwarder's own name is as real a carrier as a
7
+ * registered one, and an empty value asks the server to detect one from the
8
+ * number.
9
+ */
10
+ export const labelUploadFormSchema = z.object({
11
+ // Trimmed before the length check: every consumer trims before sending, so
12
+ // validating the raw value would let a field of spaces pass and submit
13
+ // blank.
14
+ tracking_number: z.string().trim().min(1),
15
+ carrier: z.string(),
16
+ cost: z.string(),
17
+ })
18
+
19
+ export type LabelUploadFormValues = z.infer<typeof labelUploadFormSchema>
20
+
21
+ /** What a carrier label plausibly arrives as — mirrors the server allowlist. */
22
+ export const LABEL_ACCEPT = 'application/pdf,image/png,text/plain'
@@ -1,6 +1,6 @@
1
1
  import type { LucideIcon } from 'lucide-react'
2
2
  import { type ComponentType, useSyncExternalStore } from 'react'
3
- import type { SubjectName } from './permissions'
3
+ import type { ActionName, SubjectName } from './permissions'
4
4
 
5
5
  // ============================================================================
6
6
  // Types
@@ -18,8 +18,15 @@ export interface NavVisibilityContext {
18
18
  export interface NavEntry {
19
19
  /** Stable identifier — used for register/remove/update and as the React key. */
20
20
  key: string
21
- /** Visible label. */
22
- label: string
21
+ /**
22
+ * Visible label. Built-in entries should prefer `labelKey` so the sidebar
23
+ * re-renders on language change; a literal `label` is resolved once at
24
+ * registration and then frozen. Plugins without translation bundles of their
25
+ * own can still pass a literal.
26
+ */
27
+ label?: string
28
+ /** i18n key passed to `t(...)` at render time. Takes precedence over `label`. */
29
+ labelKey?: string
23
30
  /**
24
31
  * Path template, prefixed with `/$storeId` at render time. Pass paths like
25
32
  * `'/orders'`, NOT `/store_abc/orders`. The leading slash is required.
@@ -33,6 +40,13 @@ export interface NavEntry {
33
40
  position?: number
34
41
  /** CanCanCan subject required to see this item. Omit for always-visible. */
35
42
  subject?: SubjectName
43
+ /**
44
+ * Action checked against `subject`. Defaults to `'read'`. Entries for pages
45
+ * that only exist to *change* something should declare `'update'` — every
46
+ * staff member can read the store record for shell data, so a read gate on
47
+ * `Spree::Store` shows the item to everyone.
48
+ */
49
+ action?: ActionName
36
50
  /**
37
51
  * State-based visibility gate evaluated at render (top-level entries only),
38
52
  * e.g. hide Getting Started once the store's setup tasks are all done.
@@ -92,6 +106,52 @@ function ensureUniqueKey(key: string) {
92
106
  }
93
107
  }
94
108
 
109
+ /**
110
+ * The label shape both nav registries share: a translation key, or a literal
111
+ * for plugins that ship no translation bundle of their own.
112
+ */
113
+ export interface LabelledEntry {
114
+ key: string
115
+ label?: string
116
+ labelKey?: string
117
+ }
118
+
119
+ /** Throws unless the entry can produce a visible label. */
120
+ export function ensureLabelled(entry: LabelledEntry, kind = 'Nav entry') {
121
+ if (!entry.label && !entry.labelKey) {
122
+ throw new Error(`${kind} "${entry.key}" must define either "label" or "labelKey".`)
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Resolve an entry's visible label. Prefer this over inlining the ternary —
128
+ * the fallbacks used to disagree between call sites, so an entry registered
129
+ * with neither field rendered blank on one surface and its key on another.
130
+ *
131
+ * @param entry Entry from either nav registry.
132
+ * @param t Translation function used to resolve `labelKey`.
133
+ * @returns The translated label, the literal, or the key as a last resort.
134
+ */
135
+ export function resolveNavLabel(entry: LabelledEntry, t: (key: string) => string): string {
136
+ return entry.labelKey ? t(entry.labelKey) : (entry.label ?? entry.key)
137
+ }
138
+
139
+ /** Same contract as `resolveNavLabel` for the optional description pair. */
140
+ export function resolveNavDescription(
141
+ entry: { description?: string; descriptionKey?: string },
142
+ t: (key: string) => string,
143
+ ): string | undefined {
144
+ return entry.descriptionKey ? t(entry.descriptionKey) : entry.description
145
+ }
146
+
147
+ /**
148
+ * Whether `pathname` is `url` or sits beneath it. The `/` boundary matters:
149
+ * a bare `startsWith` makes `/orders` match `/orders-archive`.
150
+ */
151
+ export function isPathWithin(pathname: string, url: string): boolean {
152
+ return pathname === url || pathname.startsWith(`${url}/`)
153
+ }
154
+
95
155
  // ============================================================================
96
156
  // Public API
97
157
  // ============================================================================
@@ -99,6 +159,7 @@ function ensureUniqueKey(key: string) {
99
159
  export const nav: NavMutator = {
100
160
  add(entry) {
101
161
  ensureUniqueKey(entry.key)
162
+ ensureLabelled(entry)
102
163
  entries.push(entry)
103
164
  notify()
104
165
  },
@@ -111,6 +172,9 @@ export const nav: NavMutator = {
111
172
  update(key, patch) {
112
173
  const e = entries.find((x) => x.key === key)
113
174
  if (!e) throw new Error(`Nav entry "${key}" not found.`)
175
+ // Validate a copy before touching the stored entry: a patch that removes
176
+ // both label fields must leave the registry as it was, not half-applied.
177
+ ensureLabelled({ ...e, ...patch })
114
178
  Object.assign(e, patch)
115
179
  notify()
116
180
  },
@@ -118,6 +182,7 @@ export const nav: NavMutator = {
118
182
  const i = findIndex(targetKey)
119
183
  if (i === -1) throw new Error(`Nav entry "${targetKey}" not found.`)
120
184
  ensureUniqueKey(entry.key)
185
+ ensureLabelled(entry)
121
186
  // Inherit target's position so the relative order survives `getNavEntries`'s sort.
122
187
  const target = entries[i]
123
188
  const adjusted: NavEntry = {
@@ -131,6 +196,7 @@ export const nav: NavMutator = {
131
196
  const i = findIndex(targetKey)
132
197
  if (i === -1) throw new Error(`Nav entry "${targetKey}" not found.`)
133
198
  ensureUniqueKey(entry.key)
199
+ ensureLabelled(entry)
134
200
  const target = entries[i]
135
201
  const adjusted: NavEntry = {
136
202
  ...entry,
@@ -141,6 +207,7 @@ export const nav: NavMutator = {
141
207
  },
142
208
  addChild(parentKey, child) {
143
209
  const parent = requireEntry(parentKey)
210
+ ensureLabelled(child)
144
211
  if (parent.children?.some((c) => c.key === child.key)) {
145
212
  throw new Error(
146
213
  `Nav child "${child.key}" already exists under "${parentKey}". Use nav.updateChild().`,
@@ -168,6 +235,7 @@ export const nav: NavMutator = {
168
235
  // reference-equality memoization on the item component.
169
236
  const next = [...parent.children]
170
237
  next[idx] = { ...next[idx], ...patch }
238
+ ensureLabelled(next[idx])
171
239
  parent.children = next
172
240
  notify()
173
241
  },