@spree/dashboard-core 0.13.0 → 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 (126) 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 +97 -36
  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 +425 -131
  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 +63 -22
  36. package/src/components/table-toolbar.tsx +1373 -442
  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 +261 -14
  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 +21 -0
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/storefront.ts +19 -0
  77. package/src/lib/table-registry.ts +56 -0
  78. package/src/lib/type-labels.test.ts +68 -0
  79. package/src/lib/type-labels.ts +78 -0
  80. package/src/locales/ar.json +1889 -516
  81. package/src/locales/de.json +1883 -515
  82. package/src/locales/en.json +1886 -516
  83. package/src/locales/fr.json +1884 -516
  84. package/src/locales/pl.json +1913 -516
  85. package/src/locales/zh-CN.json +1883 -516
  86. package/src/pages/package-types.tsx +484 -0
  87. package/src/pages/settings-index.tsx +112 -0
  88. package/src/pages/stock-locations.tsx +674 -0
  89. package/src/plugin.ts +29 -0
  90. package/src/products/add-video-dialog.tsx +100 -0
  91. package/src/products/bulk-variants-dialog.tsx +301 -0
  92. package/src/products/carton-size.test.ts +51 -0
  93. package/src/products/carton-size.ts +20 -0
  94. package/src/products/currency-parts.ts +21 -0
  95. package/src/products/inventory-section.tsx +259 -0
  96. package/src/products/media-edit-sheet.tsx +336 -0
  97. package/src/products/normalize-customs.test.ts +39 -0
  98. package/src/products/normalize-customs.ts +27 -0
  99. package/src/products/normalize-money.test.ts +58 -0
  100. package/src/products/normalize-money.ts +73 -0
  101. package/src/products/normalize-quantity.test.ts +32 -0
  102. package/src/products/normalize-quantity.ts +31 -0
  103. package/src/products/product-bulk-price-editor.tsx +165 -0
  104. package/src/products/product-form-cards.tsx +1167 -0
  105. package/src/products/product-form-mapping.ts +212 -0
  106. package/src/products/product-schema.ts +318 -0
  107. package/src/products/product-types.ts +130 -0
  108. package/src/products/use-product-form-data.ts +273 -0
  109. package/src/products/variant-edit-sheet.tsx +725 -0
  110. package/src/products/variants-matrix.test.ts +154 -0
  111. package/src/products/variants-matrix.ts +244 -0
  112. package/src/products/variants-options-builder.tsx +571 -0
  113. package/src/products/variants-section.tsx +535 -0
  114. package/src/products/video-url.test.ts +71 -0
  115. package/src/products/video-url.ts +83 -0
  116. package/src/providers/auth-provider.tsx +70 -12
  117. package/src/providers/permission-provider.tsx +68 -28
  118. package/src/providers/sticky-header-provider.tsx +134 -0
  119. package/src/providers/store-provider.tsx +36 -3
  120. package/src/providers/tenant-provider.tsx +33 -0
  121. package/src/schemas/package-type.ts +95 -0
  122. package/src/schemas/stock-location.ts +126 -0
  123. package/src/tables/package-types.tsx +74 -0
  124. package/src/tables/stock-locations.tsx +124 -0
  125. package/src/vite/index.ts +40 -7
  126. package/src/components/top-bar.tsx +0 -210
package/src/plugin.ts CHANGED
@@ -31,6 +31,7 @@
31
31
  // ],
32
32
  // })
33
33
 
34
+ import i18n from 'i18next'
34
35
  import type { CustomFieldComponent } from './lib/custom-field-components'
35
36
  import { customFieldComponents } from './lib/custom-field-components'
36
37
  import type { FormFieldRegistration } from './lib/form-fields-registry'
@@ -126,6 +127,21 @@ export interface DashboardPluginConfig {
126
127
  * custom-fields card renders it.
127
128
  */
128
129
  customFieldComponents?: Record<string, CustomFieldComponent>
130
+ /**
131
+ * Translations for the plugin's own copy, keyed by language tag:
132
+ *
133
+ * locales: {
134
+ * en: { admin: { types: { promotion_rule: { wishlist: { name: 'Wishlist' } } } } },
135
+ * de: { admin: { types: { promotion_rule: { wishlist: { name: 'Wunschliste' } } } } },
136
+ * }
137
+ *
138
+ * Merged into the base namespace, so a plugin adds keys without dropping
139
+ * the dashboard's own. This is where a gem that registers a Ruby rule,
140
+ * calculator or requirement kind puts the name merchants read: without it
141
+ * the dashboard falls back to the English label the API sends, which is
142
+ * resolved in the store's locale rather than the admin's.
143
+ */
144
+ locales?: Record<string, Record<string, unknown>>
129
145
  }
130
146
 
131
147
  /**
@@ -156,6 +172,19 @@ export function defineDashboardPlugin(config: DashboardPluginConfig): void {
156
172
  }
157
173
  }
158
174
 
175
+ // Before anything else: a nav entry or table column registered below may
176
+ // read a key this plugin ships.
177
+ if (config.locales) {
178
+ for (const [language, resources] of Object.entries(config.locales)) {
179
+ safely(
180
+ (lng: string, bundle: Record<string, unknown>) =>
181
+ i18n.addResourceBundle(lng, 'translation', bundle, true, true),
182
+ language,
183
+ resources,
184
+ )
185
+ }
186
+ }
187
+
159
188
  if (config.nav) {
160
189
  const m: NavMutations = Array.isArray(config.nav) ? { add: config.nav } : config.nav
161
190
  // add before addChildren so a plugin can add a parent then nest under it.
@@ -0,0 +1,100 @@
1
+ import { isSupportedVideoUrl } from '@spree/dashboard-core'
2
+ import {
3
+ Button,
4
+ Dialog,
5
+ DialogBody,
6
+ DialogContent,
7
+ DialogFooter,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ Field,
11
+ FieldDescription,
12
+ FieldError,
13
+ FieldLabel,
14
+ Input,
15
+ } from '@spree/dashboard-ui'
16
+ import { type FormEvent, useState } from 'react'
17
+ import { useTranslation } from 'react-i18next'
18
+
19
+ interface Props {
20
+ open: boolean
21
+ onOpenChange: (open: boolean) => void
22
+ onAdd: (url: string) => void
23
+ }
24
+
25
+ // Collects a YouTube or Vimeo link. The same check runs again on the server,
26
+ // so this is here to tell the merchant straight away rather than after a save.
27
+ export function AddVideoDialog({ open, onOpenChange, onAdd }: Props) {
28
+ const { t } = useTranslation()
29
+ const [url, setUrl] = useState('')
30
+ const [touched, setTouched] = useState(false)
31
+
32
+ const invalid = touched && url.trim().length > 0 && !isSupportedVideoUrl(url)
33
+
34
+ const close = () => {
35
+ setUrl('')
36
+ setTouched(false)
37
+ onOpenChange(false)
38
+ }
39
+
40
+ // This dialog renders inside the product form, so the submit must be stopped
41
+ // here — letting it bubble would save the whole product.
42
+ const submit = (event: FormEvent<HTMLFormElement>) => {
43
+ event.preventDefault()
44
+ event.stopPropagation()
45
+ setTouched(true)
46
+ if (!isSupportedVideoUrl(url)) return
47
+ onAdd(url.trim())
48
+ close()
49
+ }
50
+
51
+ return (
52
+ <Dialog
53
+ open={open}
54
+ onOpenChange={(next) => {
55
+ if (!next) close()
56
+ }}
57
+ >
58
+ <DialogContent>
59
+ <DialogHeader>
60
+ <DialogTitle>{t('admin.products.media.add_video_title')}</DialogTitle>
61
+ </DialogHeader>
62
+
63
+ <form onSubmit={submit} className="contents">
64
+ <DialogBody>
65
+ <Field>
66
+ <FieldLabel htmlFor="external-video-url">
67
+ {t('admin.fields.media.external_video_url.label')}
68
+ </FieldLabel>
69
+ <Input
70
+ id="external-video-url"
71
+ value={url}
72
+ autoFocus
73
+ aria-invalid={invalid || undefined}
74
+ placeholder={t('admin.fields.media.external_video_url.placeholder')}
75
+ onChange={(e) => setUrl(e.target.value)}
76
+ onBlur={() => setTouched(true)}
77
+ />
78
+ {invalid ? (
79
+ <FieldError>{t('admin.products.media.video_url_invalid')}</FieldError>
80
+ ) : (
81
+ <FieldDescription>
82
+ {t('admin.fields.media.external_video_url.help')}
83
+ </FieldDescription>
84
+ )}
85
+ </Field>
86
+ </DialogBody>
87
+
88
+ <DialogFooter>
89
+ <Button type="button" variant="outline" onClick={close}>
90
+ {t('admin.actions.cancel')}
91
+ </Button>
92
+ <Button type="submit" disabled={url.trim().length === 0}>
93
+ {t('admin.products.media.add_video_submit')}
94
+ </Button>
95
+ </DialogFooter>
96
+ </form>
97
+ </DialogContent>
98
+ </Dialog>
99
+ )
100
+ }
@@ -0,0 +1,301 @@
1
+ import type { ProductFormValues, VariantFormValues } from '@spree/dashboard-core'
2
+ import { useCountries, useCountryDisplayName } from '@spree/dashboard-core'
3
+ import {
4
+ type BulkVariantsChange,
5
+ type BulkVariantsRow,
6
+ BulkVariantsTable,
7
+ Button,
8
+ Dialog,
9
+ DialogBody,
10
+ DialogContent,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ } from '@spree/dashboard-ui'
14
+ import { useCallback, useEffect, useMemo, useRef } from 'react'
15
+ import { type UseFormReturn, useWatch } from 'react-hook-form'
16
+ import { useTranslation } from 'react-i18next'
17
+ import { cartonSize } from './carton-size'
18
+ import { normalizeCustomsDescription, normalizeHsCode } from './normalize-customs'
19
+ import {
20
+ useFormCartonPackageTypes as useCartonPackageTypes,
21
+ useFormOptionTypes as useOptionTypes,
22
+ useFormTaxCategories as useTaxCategories,
23
+ } from './use-product-form-data'
24
+ import { composeOptionsText } from './variants-matrix'
25
+
26
+ const WEIGHT_UNITS = ['g', 'kg', 'lb', 'oz'] as const
27
+ const DIMENSION_UNITS = ['mm', 'cm', 'in'] as const
28
+
29
+ interface Props {
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ form: UseFormReturn<ProductFormValues, any, any>
32
+ open: boolean
33
+ onOpenChange: (open: boolean) => void
34
+ }
35
+
36
+ /**
37
+ * Full-page bulk variant editor: a spreadsheet of every per-variant field
38
+ * from the variant edit sheet (SKU, barcode, shipping measurements + units,
39
+ * availability, tax category) across all of a product's variants at once.
40
+ * Selection, copy/paste, and drag-fill come from the shared DataGrid.
41
+ *
42
+ * Form-backed like the edit sheet: cells read from and write to the parent
43
+ * product form's `variants[]`, so Done keeps the edits in form state (the
44
+ * product Save button commits them) and Cancel restores the snapshot taken
45
+ * when the dialog opened. Closing via Esc/overlay/X cancels, mirroring the
46
+ * sheet.
47
+ */
48
+ export function BulkVariantsDialog({ form, open, onOpenChange }: Props) {
49
+ const { t } = useTranslation()
50
+ const variants = useWatch({ control: form.control, name: 'variants' }) ?? []
51
+ const { data: optionTypesData } = useOptionTypes()
52
+ const optionTypes = useMemo(() => optionTypesData?.data ?? [], [optionTypesData])
53
+ const { data: taxCategoriesResponse } = useTaxCategories()
54
+ const taxCategories = taxCategoriesResponse?.data ?? []
55
+ const { data: cartonTypesResponse } = useCartonPackageTypes()
56
+ const cartonTypes = cartonTypesResponse?.data ?? []
57
+ const { countries } = useCountries()
58
+ const countryName = useCountryDisplayName()
59
+
60
+ // ISO codes as values — customs data must survive the countries table, and
61
+ // a country record id would not.
62
+ const countryOptions = useMemo(
63
+ () =>
64
+ countries.map((country) => ({
65
+ value: country.iso,
66
+ label: countryName({ iso: country.iso, iso3: country.iso3, name: country.name }),
67
+ })),
68
+ [countries, countryName],
69
+ )
70
+
71
+ // Snapshot the variants when the dialog opens so Cancel can restore them.
72
+ // Deep-clone — `form.getValues` returns references into RHF's internal
73
+ // state, and cell edits mutate them in place (same trap the edit sheet
74
+ // documents).
75
+ const snapshotRef = useRef<VariantFormValues[] | null>(null)
76
+ useEffect(() => {
77
+ if (!open) return
78
+ snapshotRef.current = structuredClone(form.getValues('variants') ?? []) as VariantFormValues[]
79
+ }, [open, form])
80
+
81
+ const handleCancel = useCallback(() => {
82
+ const snapshot = snapshotRef.current
83
+ if (snapshot) {
84
+ // Restore element-by-element via resetField so each variant's dirty
85
+ // bit clears too (the edit sheet's Cancel does the same). The bulk
86
+ // editor never adds or removes rows, so indices are stable.
87
+ snapshot.forEach((value, index) => {
88
+ form.resetField(`variants.${index}`, { defaultValue: value })
89
+ })
90
+ }
91
+ snapshotRef.current = null
92
+ onOpenChange(false)
93
+ }, [form, onOpenChange])
94
+
95
+ const handleDone = useCallback(() => {
96
+ snapshotRef.current = null
97
+ onOpenChange(false)
98
+ }, [onOpenChange])
99
+
100
+ const rows = useMemo<BulkVariantsRow[]>(
101
+ () =>
102
+ variants.map((v, idx) => ({
103
+ id: `variant:${idx}`,
104
+ variantLabel: v.options.length > 0 ? composeOptionsText(v.options, optionTypes) : null,
105
+ sku: v.sku ?? null,
106
+ barcode: v.barcode ?? null,
107
+ weight: v.weight != null ? String(v.weight) : null,
108
+ weightUnit: v.weight_unit ?? null,
109
+ height: v.height != null ? String(v.height) : null,
110
+ width: v.width != null ? String(v.width) : null,
111
+ depth: v.depth != null ? String(v.depth) : null,
112
+ dimensionsUnit: v.dimensions_unit ?? null,
113
+ preorderable: !!v.preorderable,
114
+ backorderLimit: v.backorder_limit != null ? String(v.backorder_limit) : null,
115
+ taxCategoryId: v.tax_category_id ?? null,
116
+ hsCode: v.hs_code ?? null,
117
+ countryOfOrigin: v.country_of_origin ?? null,
118
+ customsDescription: v.customs_description ?? null,
119
+ unitsPerCarton: v.units_per_carton ?? null,
120
+ cartonPackageTypeId: v.carton_package_type_id ?? null,
121
+ cartonWeight: v.carton_weight != null ? String(v.carton_weight) : null,
122
+ cartonsPerPallet: v.cartons_per_pallet != null ? String(v.cartons_per_pallet) : null,
123
+ })),
124
+ [variants, optionTypes],
125
+ )
126
+
127
+ const handleChange = useCallback(
128
+ (rowId: string, change: BulkVariantsChange) => {
129
+ if (!rowId.startsWith('variant:')) return
130
+ const idx = Number.parseInt(rowId.slice('variant:'.length), 10)
131
+ if (Number.isNaN(idx)) return
132
+ const set = (path: Parameters<typeof form.setValue>[0], value: unknown) =>
133
+ form.setValue(path, value as never, { shouldDirty: true })
134
+
135
+ switch (change.field) {
136
+ case 'sku':
137
+ set(`variants.${idx}.sku`, change.value)
138
+ break
139
+ case 'barcode':
140
+ set(`variants.${idx}.barcode`, change.value)
141
+ break
142
+ case 'weightUnit':
143
+ set(`variants.${idx}.weight_unit`, change.value)
144
+ break
145
+ case 'dimensionsUnit':
146
+ set(`variants.${idx}.dimensions_unit`, change.value)
147
+ break
148
+ case 'taxCategoryId':
149
+ set(`variants.${idx}.tax_category_id`, change.value)
150
+ break
151
+ case 'hsCode':
152
+ set(`variants.${idx}.hs_code`, normalizeHsCode(change.value))
153
+ break
154
+ case 'countryOfOrigin':
155
+ // Already an ISO code — SelectCell resolves pasted country names
156
+ // against the option list before committing.
157
+ set(`variants.${idx}.country_of_origin`, change.value)
158
+ break
159
+ case 'customsDescription':
160
+ set(`variants.${idx}.customs_description`, normalizeCustomsDescription(change.value))
161
+ break
162
+ case 'cartonPackageTypeId':
163
+ set(`variants.${idx}.carton_package_type_id`, change.value)
164
+ break
165
+ // Counts are held as typed strings so an unusable entry can be
166
+ // reported rather than silently coerced away.
167
+ case 'unitsPerCarton':
168
+ set(`variants.${idx}.units_per_carton`, change.value)
169
+ break
170
+ case 'cartonsPerPallet':
171
+ set(`variants.${idx}.cartons_per_pallet`, change.value)
172
+ break
173
+ case 'cartonWeight': {
174
+ if (change.value == null || change.value.trim() === '') {
175
+ set(`variants.${idx}.carton_weight`, null)
176
+ break
177
+ }
178
+ const parsed = Number(change.value.trim().replace(',', '.'))
179
+ if (!Number.isFinite(parsed) || parsed < 0) break
180
+ set(`variants.${idx}.carton_weight`, parsed)
181
+ break
182
+ }
183
+ case 'preorderable':
184
+ set(`variants.${idx}.preorderable`, change.value)
185
+ break
186
+ case 'backorderLimit': {
187
+ if (change.value == null || change.value.trim() === '') {
188
+ set(`variants.${idx}.backorder_limit`, null)
189
+ break
190
+ }
191
+ const parsed = Number(change.value.trim().replace(',', '.'))
192
+ if (!Number.isFinite(parsed) || parsed < 0) break
193
+ set(`variants.${idx}.backorder_limit`, Math.trunc(parsed))
194
+ break
195
+ }
196
+ default: {
197
+ // weight / height / width / depth — decimals. Accept a comma
198
+ // decimal (the cell ships raw input); ignore anything that doesn't
199
+ // parse to a non-negative number rather than mangling it.
200
+ if (change.value == null || change.value.trim() === '') {
201
+ set(`variants.${idx}.${change.field}`, null)
202
+ break
203
+ }
204
+ const parsed = Number(change.value.trim().replace(',', '.'))
205
+ if (!Number.isFinite(parsed) || parsed < 0) break
206
+ set(`variants.${idx}.${change.field}`, parsed)
207
+ break
208
+ }
209
+ }
210
+ },
211
+ [form],
212
+ )
213
+
214
+ const unitOptions = (units: readonly string[]) => units.map((u) => ({ value: u, label: u }))
215
+
216
+ return (
217
+ <Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : handleCancel())} modal>
218
+ <DialogContent
219
+ // Edge-to-edge minus a 3-unit gutter, same overrides as the bulk
220
+ // price dialog (see BulkPriceEditorDialog for why each is needed).
221
+ className="!inset-3 !w-auto !max-w-none !translate-x-0 !translate-y-0 flex flex-col p-0"
222
+ style={{ maxHeight: 'none' }}
223
+ showCloseButton={false}
224
+ >
225
+ <DialogHeader className="flex flex-row items-center justify-between gap-3 space-y-0 border-b p-3">
226
+ <DialogTitle className="truncate">
227
+ {t('admin.products.variants.bulk_edit.title')}
228
+ </DialogTitle>
229
+ <div className="flex items-center gap-2">
230
+ <Button type="button" size="sm" variant="outline" onClick={handleCancel}>
231
+ {t('admin.actions.cancel')}
232
+ </Button>
233
+ <Button type="button" size="sm" onClick={handleDone}>
234
+ {t('admin.actions.done')}
235
+ </Button>
236
+ </div>
237
+ </DialogHeader>
238
+ {/* No gutter: the body holds nothing but the grid, so padding only
239
+ narrows the space the columns have to fit in and leaves the
240
+ table's own border floating inside the dialog's.
241
+
242
+ `DialogBody` already scrolls on y and bounds its own height, so
243
+ the grid inside it has something to size against and stays the one
244
+ element that scrolls sideways. Do not add an overflow utility
245
+ here: the base class sets the `overflow-y` longhand, which a bare
246
+ `overflow-hidden` does not override at equal specificity. */}
247
+ <DialogBody className="min-h-0 flex-1 p-0">
248
+ <BulkVariantsTable
249
+ rows={rows}
250
+ onChange={handleChange}
251
+ weightUnitOptions={unitOptions(WEIGHT_UNITS)}
252
+ dimensionUnitOptions={unitOptions(DIMENSION_UNITS)}
253
+ taxCategoryOptions={
254
+ taxCategories.length > 0
255
+ ? taxCategories.map((c) => ({ value: c.id, label: c.name }))
256
+ : undefined
257
+ }
258
+ countryOptions={countryOptions.length > 0 ? countryOptions : undefined}
259
+ cartonOptions={
260
+ cartonTypes.length > 0
261
+ ? cartonTypes.map((carton) => ({
262
+ value: carton.id,
263
+ // The name stays the label: a pasted column matches on it.
264
+ label: carton.name,
265
+ hint: cartonSize(carton) ?? undefined,
266
+ }))
267
+ : undefined
268
+ }
269
+ labels={{
270
+ variant: t('admin.pages.products.price_lists.edit_prices.columns.variant'),
271
+ sku: t('admin.fields.variant.sku.label'),
272
+ barcode: t('admin.fields.variant.barcode.label'),
273
+ weight: t('admin.fields.variant.weight.label'),
274
+ weightUnit: t('admin.fields.variant.weight_unit.label'),
275
+ height: t('admin.fields.variant.height.label'),
276
+ width: t('admin.fields.variant.width.label'),
277
+ depth: t('admin.fields.variant.depth.label'),
278
+ dimensionsUnit: t('admin.fields.variant.dimensions_unit.label'),
279
+ preorderable: t('admin.fields.variant.preorderable.label'),
280
+ backorderLimit: t('admin.fields.variant.backorder_limit.label'),
281
+ taxCategory: t('admin.fields.tax_category_id.label'),
282
+ hsCode: t('admin.fields.variant.hs_code.label'),
283
+ countryOfOrigin: t('admin.fields.variant.country_of_origin.label'),
284
+ customsDescription: t('admin.fields.variant.customs_description.label'),
285
+ unitsPerCarton: t('admin.fields.variant.units_per_carton.label'),
286
+ cartonPackageType: t('admin.fields.variant.carton_package_type_id.label'),
287
+ cartonWeight: t('admin.fields.variant.carton_weight.label'),
288
+ cartonsPerPallet: t('admin.fields.variant.cartons_per_pallet.label'),
289
+ variantDefault: t('admin.products.variants.default_variant'),
290
+ unitDefault: t('admin.products.variants.bulk_edit.unset'),
291
+ taxCategoryNone: t('admin.products.variants.sheet.tax_category_placeholder'),
292
+ countryOfOriginNone: t('admin.products.variants.bulk_edit.unset'),
293
+ cartonPackageTypeNone: t('admin.products.variants.bulk_edit.unset'),
294
+ gridAriaLabel: t('admin.products.variants.bulk_edit.grid_aria'),
295
+ }}
296
+ />
297
+ </DialogBody>
298
+ </DialogContent>
299
+ </Dialog>
300
+ )
301
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { cartonSize } from './carton-size'
3
+
4
+ describe('cartonSize', () => {
5
+ it('reads back the way a merchant would write it', () => {
6
+ expect(
7
+ cartonSize({
8
+ id: '1',
9
+ name: 'Large',
10
+ length: '60.0',
11
+ width: '40.0',
12
+ height: '40.0',
13
+ dimensions_unit: 'cm',
14
+ }),
15
+ ).toBe('60 × 40 × 40 cm')
16
+ })
17
+
18
+ it('keeps a real decimal', () => {
19
+ expect(
20
+ cartonSize({
21
+ id: '1',
22
+ name: 'Small',
23
+ length: '12.5',
24
+ width: '9.0',
25
+ height: '4.25',
26
+ dimensions_unit: 'in',
27
+ }),
28
+ ).toBe('12.5 × 9 × 4.25 in')
29
+ })
30
+
31
+ // Half a box is not a size — better to say nothing than "60 × × cm".
32
+ it('says nothing when a measurement is missing', () => {
33
+ expect(
34
+ cartonSize({
35
+ id: '1',
36
+ name: 'Partial',
37
+ length: '60.0',
38
+ width: null,
39
+ height: '40.0',
40
+ dimensions_unit: 'cm',
41
+ }),
42
+ ).toBeNull()
43
+ expect(cartonSize({ id: '1', name: 'Bare' })).toBeNull()
44
+ })
45
+
46
+ it('omits an absent unit rather than trailing a space', () => {
47
+ expect(cartonSize({ id: '1', name: 'Unitless', length: '10', width: '10', height: '10' })).toBe(
48
+ '10 × 10 × 10',
49
+ )
50
+ })
51
+ })
@@ -0,0 +1,20 @@
1
+ import type { PanelPackageType } from '../api-client'
2
+
3
+ /**
4
+ * A carton's size, as the merchant recorded it — for telling two similarly
5
+ * named cartons apart wherever one is chosen.
6
+ *
7
+ * Null unless all three measurements are there: half a box is not a size. The
8
+ * unit travels with the numbers because the same three read as inches rather
9
+ * than centimeters describe a box sixteen times the volume.
10
+ */
11
+ export function cartonSize(carton: PanelPackageType): string | null {
12
+ const { length, width, height, dimensions_unit } = carton
13
+ if (length == null || width == null || height == null) return null
14
+
15
+ // Decimals arrive as strings, so `60.0` would read back as a precision the
16
+ // merchant never typed.
17
+ const trim = (value: string) => String(Number(value))
18
+
19
+ return `${trim(length)} × ${trim(width)} × ${trim(height)} ${dimensions_unit ?? ''}`.trim()
20
+ }
@@ -0,0 +1,21 @@
1
+ // Resolve a currency code into its symbol + decimal separator under the
2
+ // current locale, with a safe fallback for unknown/invalid currencies.
3
+ // Shared between the server-backed BulkPriceEditor and the form-backed
4
+ // ProductBulkPriceEditor.
5
+ export function currencyParts(
6
+ currencyCode: string,
7
+ locale: string,
8
+ ): { symbol: string; decimal: string } {
9
+ try {
10
+ const parts = new Intl.NumberFormat(locale, {
11
+ style: 'currency',
12
+ currency: currencyCode,
13
+ }).formatToParts(1234.56)
14
+ return {
15
+ symbol: parts.find((p) => p.type === 'currency')?.value ?? currencyCode,
16
+ decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.',
17
+ }
18
+ } catch {
19
+ return { symbol: currencyCode, decimal: '.' }
20
+ }
21
+ }