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