@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
@@ -10,11 +10,11 @@ import {
10
10
  Field,
11
11
  FieldDescription,
12
12
  FieldLabel,
13
+ toastManager,
13
14
  } from '@spree/dashboard-ui'
14
- import { FileIcon, UploadCloudIcon, XIcon } from 'lucide-react'
15
+ import { FileIcon, UploadCloudIcon, XIcon } from '@spree/dashboard-ui/icons'
15
16
  import { type ReactNode, useState } from 'react'
16
17
  import { useTranslation } from 'react-i18next'
17
- import { toast } from 'sonner'
18
18
  import { useDirectUpload } from '../hooks/use-direct-upload'
19
19
 
20
20
  /**
@@ -43,7 +43,9 @@ export const EMPTY_FILE_UPLOAD_VALUE: FileUploadValue = {
43
43
  export function formatFileSize(bytes: number): string {
44
44
  if (bytes < 1024) return `${bytes} B`
45
45
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
46
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
46
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
47
+ // Digital products are sold in gigabytes — sample packs, video, disk images.
48
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
47
49
  }
48
50
 
49
51
  export interface FileUploadFieldProps {
@@ -72,6 +74,18 @@ export interface FileUploadFieldProps {
72
74
  disabled?: boolean
73
75
  /** Re-wrap the picked file before upload (e.g. force a content type). */
74
76
  transformFile?: (file: File) => File
77
+ /**
78
+ * Upload to private storage rather than the public bucket. Required for
79
+ * anything served through an authorized endpoint — attaching a signed id
80
+ * never moves a blob between services, so this has to be right up front.
81
+ */
82
+ private?: boolean
83
+ /**
84
+ * Called with true while a picked file is uploading. A consumer whose save
85
+ * sends `signedId` must disable it meanwhile — the id does not exist until
86
+ * the upload resolves, so an early submit silently drops the file.
87
+ */
88
+ onUploadingChange?: (uploading: boolean) => void
75
89
  }
76
90
 
77
91
  /**
@@ -97,9 +111,11 @@ export function FileUploadField({
97
111
  mediaClassName,
98
112
  disabled = false,
99
113
  transformFile,
114
+ private: privateStorage = false,
115
+ onUploadingChange,
100
116
  }: FileUploadFieldProps) {
101
117
  const { t } = useTranslation()
102
- const directUpload = useDirectUpload()
118
+ const directUpload = useDirectUpload({ private: privateStorage })
103
119
  const [pending, setPending] = useState<File | null>(null)
104
120
 
105
121
  const isImage = variant === 'image'
@@ -126,12 +142,16 @@ export function FileUploadField({
126
142
  async function handlePicked(picked: File) {
127
143
  if (disabled || pending) return
128
144
  if (!matchesAccept(picked)) {
129
- toast.error(t('admin.components.file_upload.invalid_type', { accept }))
145
+ toastManager.add({
146
+ type: 'error',
147
+ title: t('admin.components.file_upload.invalid_type', { accept }),
148
+ })
130
149
  return
131
150
  }
132
151
 
133
152
  const file = transformFile ? transformFile(picked) : picked
134
153
  setPending(file)
154
+ onUploadingChange?.(true)
135
155
  try {
136
156
  const result = await directUpload.mutateAsync(file)
137
157
  // Revoke a preview we're replacing in-place; the object URL otherwise
@@ -146,13 +166,15 @@ export function FileUploadField({
146
166
  byteSize: file.size,
147
167
  })
148
168
  } catch (err) {
149
- toast.error(
150
- t('admin.components.file_upload.upload_failed', {
169
+ toastManager.add({
170
+ type: 'error',
171
+ title: t('admin.components.file_upload.upload_failed', {
151
172
  message: err instanceof Error ? err.message : String(err),
152
173
  }),
153
- )
174
+ })
154
175
  } finally {
155
176
  setPending(null)
177
+ onUploadingChange?.(false)
156
178
  }
157
179
  }
158
180
 
@@ -191,7 +213,7 @@ export function FileUploadField({
191
213
  Enter/Space; the label paints a focus ring via has-[:focus-visible]. */}
192
214
  <label
193
215
  className={cn(
194
- 'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-border border-dashed bg-muted/40 px-4 py-8 text-center transition-colors hover:bg-muted',
216
+ 'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-border border-dashed px-4 py-8 text-center transition-colors hover:bg-accent/50',
195
217
  'has-[:focus-visible]:border-ring has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50',
196
218
  (disabled || !!pending) && 'pointer-events-none opacity-60',
197
219
  )}
@@ -206,7 +228,7 @@ export function FileUploadField({
206
228
  <span className="text-muted-foreground text-sm">
207
229
  {dropLabel ?? t('admin.components.file_upload.drop_label')}
208
230
  </span>
209
- <span className="mt-1 inline-flex h-8 items-center rounded-md border border-border bg-background px-3 font-medium text-sm shadow-xs">
231
+ <span className="mt-1 inline-flex h-8 items-center rounded-md border border-border bg-background px-3 font-medium text-sm hover:bg-accent">
210
232
  {browseLabel ?? t('admin.components.file_upload.browse')}
211
233
  </span>
212
234
  <input
@@ -1,4 +1,3 @@
1
- import type { Import, ImportType } from '@spree/admin-sdk'
2
1
  import {
3
2
  Button,
4
3
  Field,
@@ -14,13 +13,20 @@ import {
14
13
  SheetFooter,
15
14
  SheetHeader,
16
15
  SheetTitle,
16
+ toastManager,
17
17
  } from '@spree/dashboard-ui'
18
- import { DownloadIcon, FileSpreadsheetIcon, UploadIcon } from 'lucide-react'
18
+ import { DownloadIcon, FileSpreadsheetIcon, UploadIcon } from '@spree/dashboard-ui/icons'
19
19
  import { useState } from 'react'
20
20
  import { useTranslation } from 'react-i18next'
21
- import { toast } from 'sonner'
22
- import { useCreateImport, useDownloadImportTemplate } from '../hooks/use-import'
21
+ import type { PanelImport } from '../api-client'
22
+ import {
23
+ type ImportCreateExtras,
24
+ useCreateImport,
25
+ useDownloadImportExample,
26
+ useDownloadImportTemplate,
27
+ } from '../hooks/use-import'
23
28
  import type { SubjectName } from '../lib/permissions'
29
+ import { hasSampleCsv } from '../lib/sample-csv'
24
30
  import { Can } from './can'
25
31
  import { EMPTY_FILE_UPLOAD_VALUE, FileUploadField, type FileUploadValue } from './file-upload-field'
26
32
 
@@ -35,7 +41,7 @@ type Delimiter = (typeof DELIMITERS)[number]['value']
35
41
 
36
42
  interface ImportButtonProps {
37
43
  /** Which dataset to import. Server validates against `Spree::Import.available_types`. */
38
- type: ImportType
44
+ type: string
39
45
  /**
40
46
  * CanCanCan subject gating the button — the *imported resource*
41
47
  * (e.g. `Subject.Product`), mirroring the server's `write_<resource>`
@@ -47,9 +53,24 @@ interface ImportButtonProps {
47
53
  * wizard — typically a full-window dialog driven by an `?import=` search
48
54
  * param.
49
55
  */
50
- onCreated: (imp: Import) => void
56
+ onCreated: (imp: PanelImport) => void
51
57
  /** Label shown on the button. Defaults to the translated "Import" action. */
52
58
  label?: string
59
+ /** Optional variant for the button. Defaults to "outline". */
60
+ variant?: 'default' | 'outline' | 'ghost'
61
+ /** Optional size for the button. Defaults to "sm". */
62
+ size?: 'sm' | 'default' | 'lg'
63
+ /**
64
+ * Whatever else the type needs at create — a parent record's id, say. The
65
+ * button then belongs on that record's page, and the sheet asks nothing.
66
+ */
67
+ params?: ImportCreateExtras
68
+ /**
69
+ * Path the import-done email links back to, relative to this panel's origin.
70
+ * Defaults to the operator dashboard's imports view under the current
71
+ * tenant; a panel filing that page elsewhere passes its own.
72
+ */
73
+ resultsPath?: string
53
74
  }
54
75
 
55
76
  /**
@@ -58,13 +79,24 @@ interface ImportButtonProps {
58
79
  * `FileUploadField`; submitting creates the import from the signed blob id
59
80
  * and hands it to `onCreated`.
60
81
  */
61
- export function ImportButton({ type, subject, onCreated, label }: ImportButtonProps) {
82
+ export function ImportButton({
83
+ type,
84
+ subject,
85
+ onCreated,
86
+ label,
87
+ variant,
88
+ size,
89
+ params,
90
+ resultsPath,
91
+ }: ImportButtonProps) {
62
92
  const { t } = useTranslation()
63
93
  const [open, setOpen] = useState(false)
64
94
  const [file, setFile] = useState<FileUploadValue>(EMPTY_FILE_UPLOAD_VALUE)
65
95
  const [delimiter, setDelimiter] = useState<Delimiter>(',')
66
96
  const createImport = useCreateImport()
67
97
  const downloadTemplate = useDownloadImportTemplate()
98
+ const downloadExample = useDownloadImportExample()
99
+ const hasExample = hasSampleCsv(type)
68
100
 
69
101
  const delimiterOptions = DELIMITERS.map(({ value, labelKey }) => ({
70
102
  value,
@@ -80,18 +112,19 @@ export function ImportButton({ type, subject, onCreated, label }: ImportButtonPr
80
112
  if (!file.signedId || createImport.isPending) return
81
113
 
82
114
  createImport.mutate(
83
- { type, signedId: file.signedId, preferredDelimiter: delimiter },
115
+ { type, signedId: file.signedId, preferredDelimiter: delimiter, params, resultsPath },
84
116
  {
85
117
  onSuccess: (imp) => {
86
118
  handleOpenChange(false)
87
119
  onCreated(imp)
88
120
  },
89
121
  onError: (err) => {
90
- toast.error(
91
- t('admin.components.import_button.failed', {
122
+ toastManager.add({
123
+ type: 'error',
124
+ title: t('admin.components.import_button.failed', {
92
125
  message: err instanceof Error ? err.message : String(err),
93
126
  }),
94
- )
127
+ })
95
128
  },
96
129
  },
97
130
  )
@@ -100,25 +133,42 @@ export function ImportButton({ type, subject, onCreated, label }: ImportButtonPr
100
133
  function handleTemplateDownload() {
101
134
  downloadTemplate.mutate(type, {
102
135
  onError: (err) => {
103
- toast.error(
104
- t('admin.components.import_button.template_failed', {
136
+ toastManager.add({
137
+ type: 'error',
138
+ title: t('admin.components.import_button.template_failed', {
105
139
  message: err instanceof Error ? err.message : String(err),
106
140
  }),
107
- )
141
+ })
142
+ },
143
+ })
144
+ }
145
+
146
+ function handleExampleDownload() {
147
+ downloadExample.mutate(type, {
148
+ onError: (err) => {
149
+ toastManager.add({
150
+ type: 'error',
151
+ title: t('admin.components.import_button.example_failed', {
152
+ message: err instanceof Error ? err.message : String(err),
153
+ }),
154
+ })
108
155
  },
109
156
  })
110
157
  }
111
158
 
112
159
  return (
113
160
  <Can I="create" a={subject}>
161
+ {/* Desktop only: the wizard needs a file from disk and column
162
+ mapping across a wide grid, neither of which works on a phone. */}
114
163
  <Button
115
- size="sm"
116
- variant="outline"
117
- className="h-[2.125rem]"
164
+ type="button"
165
+ size={size ?? 'sm'}
166
+ variant={variant ?? 'outline'}
167
+ className="hidden h-[2.125rem] lg:inline-flex"
118
168
  onClick={() => setOpen(true)}
119
169
  disabled={createImport.isPending}
120
170
  >
121
- <UploadIcon className="size-4" />
171
+ <UploadIcon />
122
172
  {label ?? t('admin.actions.import')}
123
173
  </Button>
124
174
 
@@ -162,31 +212,42 @@ export function ImportButton({ type, subject, onCreated, label }: ImportButtonPr
162
212
  </Select>
163
213
  </Field>
164
214
 
165
- <Button
166
- type="button"
167
- variant="ghost"
168
- size="sm"
169
- className="self-start"
170
- onClick={handleTemplateDownload}
171
- disabled={downloadTemplate.isPending}
172
- >
173
- <DownloadIcon className="size-4" />
174
- {t('admin.components.import_button.download_template')}
175
- </Button>
215
+ <div className="flex flex-col items-start gap-1">
216
+ <Button
217
+ type="button"
218
+ variant="ghost"
219
+ size="sm"
220
+ onClick={handleTemplateDownload}
221
+ disabled={downloadTemplate.isPending}
222
+ >
223
+ <DownloadIcon className="size-4" />
224
+ {t('admin.components.import_button.download_template')}
225
+ </Button>
226
+
227
+ {/* The populated counterpart to the headers-only template above.
228
+ Fetched through the API so the file matches the installed
229
+ Spree version's schema. */}
230
+ {hasExample && (
231
+ <Button
232
+ type="button"
233
+ variant="ghost"
234
+ size="sm"
235
+ onClick={handleExampleDownload}
236
+ disabled={downloadExample.isPending}
237
+ >
238
+ <FileSpreadsheetIcon className="size-4" />
239
+ {t('admin.components.import_button.download_example')}
240
+ </Button>
241
+ )}
242
+ </div>
176
243
  </div>
177
244
 
178
245
  <SheetFooter>
179
- <Button
180
- type="button"
181
- variant="outline"
182
- size="sm"
183
- onClick={() => handleOpenChange(false)}
184
- >
246
+ <Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
185
247
  {t('admin.actions.cancel')}
186
248
  </Button>
187
249
  <Button
188
250
  type="button"
189
- size="sm"
190
251
  onClick={handleSubmit}
191
252
  disabled={!file.signedId || createImport.isPending}
192
253
  >