@spree/dashboard-core 0.1.0

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 (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +418 -0
  3. package/package.json +84 -0
  4. package/src/client.ts +25 -0
  5. package/src/components/address-form-dialog.tsx +376 -0
  6. package/src/components/app-sidebar.tsx +70 -0
  7. package/src/components/bulk-action-bar.tsx +504 -0
  8. package/src/components/can.tsx +30 -0
  9. package/src/components/country-combobox.tsx +238 -0
  10. package/src/components/country-state-fields.tsx +86 -0
  11. package/src/components/currency-select.tsx +181 -0
  12. package/src/components/export-button.tsx +156 -0
  13. package/src/components/file-upload-field.tsx +265 -0
  14. package/src/components/image-upload-field.tsx +53 -0
  15. package/src/components/import-button.tsx +202 -0
  16. package/src/components/locale-select.tsx +268 -0
  17. package/src/components/market-combobox.tsx +89 -0
  18. package/src/components/nav-main.tsx +186 -0
  19. package/src/components/page-header.tsx +286 -0
  20. package/src/components/page-tabs.tsx +78 -0
  21. package/src/components/preferences-form.tsx +404 -0
  22. package/src/components/resource-combobox.tsx +126 -0
  23. package/src/components/resource-multi-autocomplete.tsx +176 -0
  24. package/src/components/resource-picker-sheet.tsx +235 -0
  25. package/src/components/resource-table.tsx +652 -0
  26. package/src/components/settings-sidebar.tsx +139 -0
  27. package/src/components/slot.tsx +50 -0
  28. package/src/components/store-date-picker.tsx +18 -0
  29. package/src/components/store-switcher.tsx +70 -0
  30. package/src/components/table-toolbar.tsx +818 -0
  31. package/src/components/tag-combobox.tsx +99 -0
  32. package/src/components/top-bar.tsx +210 -0
  33. package/src/hooks/use-auth.ts +10 -0
  34. package/src/hooks/use-command-palette.tsx +34 -0
  35. package/src/hooks/use-countries.ts +15 -0
  36. package/src/hooks/use-custom-fields.ts +142 -0
  37. package/src/hooks/use-direct-upload.ts +104 -0
  38. package/src/hooks/use-display-name.ts +42 -0
  39. package/src/hooks/use-export.ts +75 -0
  40. package/src/hooks/use-global-search.ts +79 -0
  41. package/src/hooks/use-host-form.ts +34 -0
  42. package/src/hooks/use-import.ts +62 -0
  43. package/src/hooks/use-resource-mutation.ts +99 -0
  44. package/src/hooks/use-switch-admin-locale.ts +55 -0
  45. package/src/index.ts +97 -0
  46. package/src/lib/custom-field-components.ts +56 -0
  47. package/src/lib/download.ts +49 -0
  48. package/src/lib/filters-to-ransack.ts +47 -0
  49. package/src/lib/form-errors.ts +103 -0
  50. package/src/lib/form-fields-registry.ts +102 -0
  51. package/src/lib/form-mappers.ts +30 -0
  52. package/src/lib/formatters.ts +19 -0
  53. package/src/lib/i18n.ts +210 -0
  54. package/src/lib/nav-registry.ts +224 -0
  55. package/src/lib/permissions.ts +55 -0
  56. package/src/lib/query-client.ts +11 -0
  57. package/src/lib/query-keys.ts +69 -0
  58. package/src/lib/route-registry.ts +158 -0
  59. package/src/lib/search-registry.ts +129 -0
  60. package/src/lib/settings-nav-registry.ts +158 -0
  61. package/src/lib/slot-registry.ts +116 -0
  62. package/src/lib/table-registry.ts +274 -0
  63. package/src/lib/timestamp-columns.tsx +58 -0
  64. package/src/locales/ar.json +629 -0
  65. package/src/locales/de.json +629 -0
  66. package/src/locales/en.json +629 -0
  67. package/src/locales/fr.json +629 -0
  68. package/src/locales/pl.json +629 -0
  69. package/src/locales/zh-CN.json +629 -0
  70. package/src/plugin.ts +222 -0
  71. package/src/providers/auth-provider.tsx +188 -0
  72. package/src/providers/permission-provider.tsx +118 -0
  73. package/src/providers/store-provider.tsx +111 -0
  74. package/src/virtual.d.ts +5 -0
  75. package/src/vite/discover.ts +170 -0
  76. package/src/vite/index.ts +270 -0
@@ -0,0 +1,265 @@
1
+ import {
2
+ Attachment,
3
+ AttachmentAction,
4
+ AttachmentActions,
5
+ AttachmentContent,
6
+ AttachmentDescription,
7
+ AttachmentMedia,
8
+ AttachmentTitle,
9
+ cn,
10
+ Field,
11
+ FieldDescription,
12
+ FieldLabel,
13
+ } from '@spree/dashboard-ui'
14
+ import { FileIcon, UploadCloudIcon, XIcon } from 'lucide-react'
15
+ import { type ReactNode, useState } from 'react'
16
+ import { useTranslation } from 'react-i18next'
17
+ import { toast } from 'sonner'
18
+ import { useDirectUpload } from '../hooks/use-direct-upload'
19
+
20
+ /**
21
+ * Controlled upload state. A field is in exactly one of three states:
22
+ * - untouched: `signedId == null && !cleared` → caller omits it on save
23
+ * - uploaded: `signedId` set → caller sends the signed_id
24
+ * - cleared: `cleared == true` → caller sends null to purge
25
+ *
26
+ * `previewUrl` is a transient object URL for the just-picked file (image
27
+ * uploads); `filename`/`byteSize` describe the picked file for display.
28
+ */
29
+ export interface FileUploadValue {
30
+ signedId: string | null
31
+ previewUrl: string | null
32
+ cleared: boolean
33
+ filename?: string | null
34
+ byteSize?: number | null
35
+ }
36
+
37
+ export const EMPTY_FILE_UPLOAD_VALUE: FileUploadValue = {
38
+ signedId: null,
39
+ previewUrl: null,
40
+ cleared: false,
41
+ }
42
+
43
+ export function formatFileSize(bytes: number): string {
44
+ if (bytes < 1024) return `${bytes} B`
45
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
46
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
47
+ }
48
+
49
+ export interface FileUploadFieldProps {
50
+ /** Current upload state. */
51
+ value: FileUploadValue
52
+ onChange: (value: FileUploadValue) => void
53
+ /** Accepted file types (input `accept` attribute). */
54
+ accept: string
55
+ /** `image` renders a thumbnail preview; `file` renders a type icon. */
56
+ variant?: 'image' | 'file'
57
+ /** Icon shown in the attachment media for the `file` variant. */
58
+ icon?: ReactNode
59
+ /** URL of the already-persisted attachment, shown until replaced/removed. */
60
+ serverUrl?: string | null
61
+ /** Display name for the persisted attachment (file variant). */
62
+ serverFilename?: string | null
63
+ label?: string
64
+ /** Helper text under the dropzone. */
65
+ help?: string
66
+ /** Copy inside the dropzone. Defaults to the generic "drop a file" string. */
67
+ dropLabel?: string
68
+ /** Copy on the browse chip inside the dropzone. */
69
+ browseLabel?: string
70
+ /** Extra classes for the attachment media (e.g. a larger logo preview). */
71
+ mediaClassName?: string
72
+ disabled?: boolean
73
+ /** Re-wrap the picked file before upload (e.g. force a content type). */
74
+ transformFile?: (file: File) => File
75
+ }
76
+
77
+ /**
78
+ * Universal single-file upload field backed by ActiveStorage direct upload.
79
+ * Picking or dropping a file uploads it immediately; the attached file (or
80
+ * the persisted server-side one) renders as an `Attachment` with a remove
81
+ * action. Fully controlled — the consumer maps {@link FileUploadValue} onto
82
+ * its form and decides how to serialize it (send `signed_id`, send `null`
83
+ * to clear, or omit).
84
+ */
85
+ export function FileUploadField({
86
+ value,
87
+ onChange,
88
+ accept,
89
+ variant = 'file',
90
+ icon,
91
+ serverUrl,
92
+ serverFilename,
93
+ label,
94
+ help,
95
+ dropLabel,
96
+ browseLabel,
97
+ mediaClassName,
98
+ disabled = false,
99
+ transformFile,
100
+ }: FileUploadFieldProps) {
101
+ const { t } = useTranslation()
102
+ const directUpload = useDirectUpload()
103
+ const [pending, setPending] = useState<File | null>(null)
104
+
105
+ const isImage = variant === 'image'
106
+
107
+ // The input's `accept` only hints the OS picker and does nothing for
108
+ // drag-and-drop — enforce it for both paths. Tokens are extensions
109
+ // (".csv"), exact MIME types ("text/csv") or wildcards ("image/*").
110
+ function matchesAccept(file: File): boolean {
111
+ const tokens = accept
112
+ .split(',')
113
+ .map((token) => token.trim().toLowerCase())
114
+ .filter(Boolean)
115
+ if (tokens.length === 0) return true
116
+
117
+ const name = file.name.toLowerCase()
118
+ const type = file.type.toLowerCase()
119
+ return tokens.some((token) => {
120
+ if (token.startsWith('.')) return name.endsWith(token)
121
+ if (token.endsWith('/*')) return type.startsWith(token.slice(0, -1))
122
+ return type === token
123
+ })
124
+ }
125
+
126
+ async function handlePicked(picked: File) {
127
+ if (disabled || pending) return
128
+ if (!matchesAccept(picked)) {
129
+ toast.error(t('admin.components.file_upload.invalid_type', { accept }))
130
+ return
131
+ }
132
+
133
+ const file = transformFile ? transformFile(picked) : picked
134
+ setPending(file)
135
+ try {
136
+ const result = await directUpload.mutateAsync(file)
137
+ // Revoke a preview we're replacing in-place; the object URL otherwise
138
+ // lives in caller-owned state, so its lifetime is their responsibility
139
+ // (a hidden-then-shown card must not remount with a dead URL).
140
+ if (value.previewUrl) URL.revokeObjectURL(value.previewUrl)
141
+ onChange({
142
+ signedId: result.signedId,
143
+ previewUrl: isImage ? result.previewUrl : null,
144
+ cleared: false,
145
+ filename: file.name,
146
+ byteSize: file.size,
147
+ })
148
+ } catch (err) {
149
+ toast.error(
150
+ t('admin.components.file_upload.upload_failed', {
151
+ message: err instanceof Error ? err.message : String(err),
152
+ }),
153
+ )
154
+ } finally {
155
+ setPending(null)
156
+ }
157
+ }
158
+
159
+ function handleRemove() {
160
+ if (value.previewUrl) URL.revokeObjectURL(value.previewUrl)
161
+ onChange({ signedId: null, previewUrl: null, cleared: true, filename: null, byteSize: null })
162
+ }
163
+
164
+ // What the attachment row shows: the in-flight pick, the uploaded file, or
165
+ // the persisted server-side attachment (until the user clears it).
166
+ const attached = pending
167
+ ? { state: 'uploading' as const, name: pending.name, size: pending.size, preview: null }
168
+ : value.signedId
169
+ ? {
170
+ state: 'done' as const,
171
+ name: value.filename ?? null,
172
+ size: value.byteSize ?? null,
173
+ preview: value.previewUrl,
174
+ }
175
+ : !value.cleared && (serverUrl || serverFilename)
176
+ ? {
177
+ state: 'done' as const,
178
+ name: serverFilename ?? null,
179
+ size: null,
180
+ preview: serverUrl ?? null,
181
+ }
182
+ : null
183
+
184
+ return (
185
+ <Field>
186
+ {label && <FieldLabel>{label}</FieldLabel>}
187
+ <div className="flex flex-col gap-2">
188
+ {/* A <label> wrapping the file input: clicking anywhere opens the
189
+ picker natively. The input is sr-only (not display:none) so it
190
+ stays in the tab order — keyboard users Tab to it and press
191
+ Enter/Space; the label paints a focus ring via has-[:focus-visible]. */}
192
+ <label
193
+ 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',
195
+ 'has-[:focus-visible]:border-ring has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50',
196
+ (disabled || !!pending) && 'pointer-events-none opacity-60',
197
+ )}
198
+ onDragOver={(e) => e.preventDefault()}
199
+ onDrop={(e) => {
200
+ e.preventDefault()
201
+ const dropped = e.dataTransfer.files?.[0]
202
+ if (dropped) void handlePicked(dropped)
203
+ }}
204
+ >
205
+ <UploadCloudIcon className="size-6 text-muted-foreground" />
206
+ <span className="text-muted-foreground text-sm">
207
+ {dropLabel ?? t('admin.components.file_upload.drop_label')}
208
+ </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">
210
+ {browseLabel ?? t('admin.components.file_upload.browse')}
211
+ </span>
212
+ <input
213
+ type="file"
214
+ accept={accept}
215
+ className="sr-only"
216
+ disabled={disabled || !!pending}
217
+ onChange={(e) => {
218
+ const picked = e.target.files?.[0]
219
+ if (picked) void handlePicked(picked)
220
+ e.target.value = ''
221
+ }}
222
+ />
223
+ </label>
224
+
225
+ {attached && (
226
+ <Attachment className="w-full" state={attached.state}>
227
+ <AttachmentMedia
228
+ variant={isImage && attached.preview ? 'image' : 'icon'}
229
+ className={mediaClassName}
230
+ >
231
+ {isImage && attached.preview ? (
232
+ <img src={attached.preview} alt="" className="size-full object-contain" />
233
+ ) : (
234
+ (icon ?? <FileIcon />)
235
+ )}
236
+ </AttachmentMedia>
237
+ <AttachmentContent>
238
+ <AttachmentTitle>
239
+ {attached.name ?? t('admin.components.file_upload.attached')}
240
+ </AttachmentTitle>
241
+ <AttachmentDescription>
242
+ {attached.state === 'uploading'
243
+ ? t('admin.components.file_upload.uploading')
244
+ : attached.size != null
245
+ ? formatFileSize(attached.size)
246
+ : null}
247
+ </AttachmentDescription>
248
+ </AttachmentContent>
249
+ <AttachmentActions>
250
+ <AttachmentAction
251
+ aria-label={t('admin.actions.remove')}
252
+ onClick={handleRemove}
253
+ disabled={disabled || !!pending}
254
+ >
255
+ <XIcon />
256
+ </AttachmentAction>
257
+ </AttachmentActions>
258
+ </Attachment>
259
+ )}
260
+
261
+ {help && <FieldDescription>{help}</FieldDescription>}
262
+ </div>
263
+ </Field>
264
+ )
265
+ }
@@ -0,0 +1,53 @@
1
+ import { useTranslation } from 'react-i18next'
2
+ import { FileUploadField, type FileUploadValue } from './file-upload-field'
3
+
4
+ /** @deprecated Use {@link FileUploadValue} — same shape, universal name. */
5
+ export type ImageUploadValue = FileUploadValue
6
+
7
+ export interface ImageUploadFieldProps {
8
+ value: ImageUploadValue
9
+ onChange: (value: ImageUploadValue) => void
10
+ /** URL of the already-persisted image, shown until the user replaces/removes it. */
11
+ serverUrl?: string | null
12
+ label?: string
13
+ /** Helper text under the dropzone. */
14
+ help?: string
15
+ /** Square preview (avatars); default is a wider logo-friendly box. */
16
+ square?: boolean
17
+ /** Accepted MIME types. Defaults to PNG/JPEG/WebP. */
18
+ accept?: string
19
+ disabled?: boolean
20
+ }
21
+
22
+ /**
23
+ * Single-image preset of {@link FileUploadField} — logos, avatars, category
24
+ * images. Kept as a named export so existing call sites stay drop-in.
25
+ */
26
+ export function ImageUploadField({
27
+ value,
28
+ onChange,
29
+ serverUrl,
30
+ label,
31
+ help,
32
+ square = false,
33
+ accept = 'image/png,image/jpeg,image/webp',
34
+ disabled = false,
35
+ }: ImageUploadFieldProps) {
36
+ const { t } = useTranslation()
37
+
38
+ return (
39
+ <FileUploadField
40
+ variant="image"
41
+ value={value}
42
+ onChange={onChange}
43
+ accept={accept}
44
+ serverUrl={serverUrl}
45
+ label={label}
46
+ help={help}
47
+ disabled={disabled}
48
+ dropLabel={t('admin.components.file_upload.drop_image_label')}
49
+ browseLabel={t('admin.image_upload.upload_cta')}
50
+ mediaClassName={square ? 'size-16' : 'h-16 w-28'}
51
+ />
52
+ )
53
+ }
@@ -0,0 +1,202 @@
1
+ import type { Import, ImportType } from '@spree/admin-sdk'
2
+ import {
3
+ Button,
4
+ Field,
5
+ FieldLabel,
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ Sheet,
12
+ SheetContent,
13
+ SheetDescription,
14
+ SheetFooter,
15
+ SheetHeader,
16
+ SheetTitle,
17
+ } from '@spree/dashboard-ui'
18
+ import { DownloadIcon, FileSpreadsheetIcon, UploadIcon } from 'lucide-react'
19
+ import { useState } from 'react'
20
+ import { useTranslation } from 'react-i18next'
21
+ import { toast } from 'sonner'
22
+ import { useCreateImport, useDownloadImportTemplate } from '../hooks/use-import'
23
+ import type { SubjectName } from '../lib/permissions'
24
+ import { Can } from './can'
25
+ import { EMPTY_FILE_UPLOAD_VALUE, FileUploadField, type FileUploadValue } from './file-upload-field'
26
+
27
+ const DELIMITERS = [
28
+ { value: ',', labelKey: 'comma' },
29
+ { value: ';', labelKey: 'semicolon' },
30
+ { value: '|', labelKey: 'pipe' },
31
+ { value: '\t', labelKey: 'tab' },
32
+ ] as const
33
+
34
+ type Delimiter = (typeof DELIMITERS)[number]['value']
35
+
36
+ interface ImportButtonProps {
37
+ /** Which dataset to import. Server validates against `Spree::Import.available_types`. */
38
+ type: ImportType
39
+ /**
40
+ * CanCanCan subject gating the button — the *imported resource*
41
+ * (e.g. `Subject.Product`), mirroring the server's `write_<resource>`
42
+ * scope model. Purely UX; the backend authorizes every request.
43
+ */
44
+ subject: SubjectName
45
+ /**
46
+ * Receives the created import (in `mapping` state). The consumer opens the
47
+ * wizard — typically a full-window dialog driven by an `?import=` search
48
+ * param.
49
+ */
50
+ onCreated: (imp: Import) => void
51
+ /** Label shown on the button. Defaults to the translated "Import" action. */
52
+ label?: string
53
+ }
54
+
55
+ /**
56
+ * Toolbar entry point for CSV imports: opens a Sheet with the upload form
57
+ * (file, delimiter, template download). The CSV direct-uploads on pick via
58
+ * `FileUploadField`; submitting creates the import from the signed blob id
59
+ * and hands it to `onCreated`.
60
+ */
61
+ export function ImportButton({ type, subject, onCreated, label }: ImportButtonProps) {
62
+ const { t } = useTranslation()
63
+ const [open, setOpen] = useState(false)
64
+ const [file, setFile] = useState<FileUploadValue>(EMPTY_FILE_UPLOAD_VALUE)
65
+ const [delimiter, setDelimiter] = useState<Delimiter>(',')
66
+ const createImport = useCreateImport()
67
+ const downloadTemplate = useDownloadImportTemplate()
68
+
69
+ const delimiterOptions = DELIMITERS.map(({ value, labelKey }) => ({
70
+ value,
71
+ label: t(`admin.components.import_button.delimiters.${labelKey}`),
72
+ }))
73
+
74
+ function handleOpenChange(next: boolean) {
75
+ setOpen(next)
76
+ if (!next) setFile(EMPTY_FILE_UPLOAD_VALUE)
77
+ }
78
+
79
+ function handleSubmit() {
80
+ if (!file.signedId || createImport.isPending) return
81
+
82
+ createImport.mutate(
83
+ { type, signedId: file.signedId, preferredDelimiter: delimiter },
84
+ {
85
+ onSuccess: (imp) => {
86
+ handleOpenChange(false)
87
+ onCreated(imp)
88
+ },
89
+ onError: (err) => {
90
+ toast.error(
91
+ t('admin.components.import_button.failed', {
92
+ message: err instanceof Error ? err.message : String(err),
93
+ }),
94
+ )
95
+ },
96
+ },
97
+ )
98
+ }
99
+
100
+ function handleTemplateDownload() {
101
+ downloadTemplate.mutate(type, {
102
+ onError: (err) => {
103
+ toast.error(
104
+ t('admin.components.import_button.template_failed', {
105
+ message: err instanceof Error ? err.message : String(err),
106
+ }),
107
+ )
108
+ },
109
+ })
110
+ }
111
+
112
+ return (
113
+ <Can I="create" a={subject}>
114
+ <Button
115
+ size="sm"
116
+ variant="outline"
117
+ className="h-[2.125rem]"
118
+ onClick={() => setOpen(true)}
119
+ disabled={createImport.isPending}
120
+ >
121
+ <UploadIcon className="size-4" />
122
+ {label ?? t('admin.actions.import')}
123
+ </Button>
124
+
125
+ <Sheet open={open} onOpenChange={handleOpenChange}>
126
+ <SheetContent className="sm:max-w-xl">
127
+ <SheetHeader>
128
+ <SheetTitle>{t('admin.components.import_button.title')}</SheetTitle>
129
+ <SheetDescription>{t('admin.components.import_button.description')}</SheetDescription>
130
+ </SheetHeader>
131
+
132
+ <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
133
+ <FileUploadField
134
+ value={file}
135
+ onChange={setFile}
136
+ accept=".csv,text/csv"
137
+ variant="file"
138
+ icon={<FileSpreadsheetIcon />}
139
+ dropLabel={t('admin.components.import_button.drop_label')}
140
+ // Windows browsers report `.csv` as `application/vnd.ms-excel`,
141
+ // which the server's content-type validation rejects.
142
+ transformFile={(picked) => new File([picked], picked.name, { type: 'text/csv' })}
143
+ />
144
+
145
+ <Field>
146
+ <FieldLabel>{t('admin.components.import_button.delimiter_label')}</FieldLabel>
147
+ <Select
148
+ items={delimiterOptions}
149
+ value={delimiter}
150
+ onValueChange={(value) => setDelimiter(value as Delimiter)}
151
+ >
152
+ <SelectTrigger>
153
+ <SelectValue />
154
+ </SelectTrigger>
155
+ <SelectContent>
156
+ {delimiterOptions.map((option) => (
157
+ <SelectItem key={option.value} value={option.value}>
158
+ {option.label}
159
+ </SelectItem>
160
+ ))}
161
+ </SelectContent>
162
+ </Select>
163
+ </Field>
164
+
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>
176
+ </div>
177
+
178
+ <SheetFooter>
179
+ <Button
180
+ type="button"
181
+ variant="outline"
182
+ size="sm"
183
+ onClick={() => handleOpenChange(false)}
184
+ >
185
+ {t('admin.actions.cancel')}
186
+ </Button>
187
+ <Button
188
+ type="button"
189
+ size="sm"
190
+ onClick={handleSubmit}
191
+ disabled={!file.signedId || createImport.isPending}
192
+ >
193
+ {createImport.isPending
194
+ ? t('admin.actions.creating')
195
+ : t('admin.components.import_button.submit')}
196
+ </Button>
197
+ </SheetFooter>
198
+ </SheetContent>
199
+ </Sheet>
200
+ </Can>
201
+ )
202
+ }