@spree/dashboard-core 1.0.0-rc.5 → 1.0.0-rc.6

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.
@@ -0,0 +1,88 @@
1
+ import type { DigitalAssetProvider } from '@spree/admin-sdk'
2
+ import {
3
+ Button,
4
+ Sheet,
5
+ SheetContent,
6
+ SheetFooter,
7
+ SheetHeader,
8
+ SheetTitle,
9
+ } from '@spree/dashboard-ui'
10
+ import { useEffect, useState } from 'react'
11
+ import { useTranslation } from 'react-i18next'
12
+ import { defaultProviderSettings, ProviderSettingsFields } from './provider-settings-fields'
13
+ import { useCreateDigitalAsset } from './use-digital-assets'
14
+
15
+ interface Props {
16
+ productId: string
17
+ /** The picked source, or null when the sheet is closed. */
18
+ provider: DigitalAssetProvider | null
19
+ onOpenChange: (open: boolean) => void
20
+ onCreated: () => void
21
+ }
22
+
23
+ /**
24
+ * Captures a provider-backed asset's settings before creating it. Only opened
25
+ * for a provider that declares fields; a settings-free provider creates in one
26
+ * click from the source menu without this sheet.
27
+ */
28
+ export function DigitalAssetProviderSheet({ productId, provider, onOpenChange, onCreated }: Props) {
29
+ const { t } = useTranslation()
30
+ const createAsset = useCreateDigitalAsset(productId)
31
+ const [values, setValues] = useState<Record<string, unknown>>({})
32
+ const [error, setError] = useState<string | null>(null)
33
+
34
+ // Re-seed with the provider's defaults each time a source is picked.
35
+ useEffect(() => {
36
+ if (provider) {
37
+ setValues(defaultProviderSettings(provider.settings_schema))
38
+ setError(null)
39
+ }
40
+ }, [provider])
41
+
42
+ if (!provider) return null
43
+
44
+ async function handleCreate() {
45
+ setError(null)
46
+ try {
47
+ await createAsset.mutateAsync({
48
+ provider_type: provider!.type,
49
+ provider_settings: values,
50
+ })
51
+ onCreated()
52
+ onOpenChange(false)
53
+ } catch (err) {
54
+ // useResourceMutation suppresses the toast for a 422, so surface the
55
+ // reason inline and keep the sheet open with the entered values.
56
+ setError(err instanceof Error ? err.message : t('admin.errors.unexpected'))
57
+ }
58
+ }
59
+
60
+ return (
61
+ <Sheet open={Boolean(provider)} onOpenChange={onOpenChange}>
62
+ <SheetContent side="right" className="flex flex-col">
63
+ <SheetHeader>
64
+ <SheetTitle>{provider.name}</SheetTitle>
65
+ </SheetHeader>
66
+
67
+ <div className="flex flex-1 flex-col gap-6 overflow-y-auto p-4">
68
+ <ProviderSettingsFields
69
+ schema={provider.settings_schema}
70
+ values={values}
71
+ onChange={setValues}
72
+ />
73
+
74
+ {error && <p className="text-destructive text-sm">{error}</p>}
75
+ </div>
76
+
77
+ <SheetFooter>
78
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
79
+ {t('admin.actions.cancel')}
80
+ </Button>
81
+ <Button type="button" onClick={handleCreate} disabled={createAsset.isPending}>
82
+ {createAsset.isPending ? t('admin.actions.saving') : t('admin.actions.add')}
83
+ </Button>
84
+ </SheetFooter>
85
+ </SheetContent>
86
+ </Sheet>
87
+ )
88
+ }
@@ -0,0 +1,451 @@
1
+ import type { DigitalAssetProvider } from '@spree/admin-sdk'
2
+ import {
3
+ formatFileSize,
4
+ getApiClient,
5
+ Subject,
6
+ useFileUpload,
7
+ usePermissions,
8
+ } from '@spree/dashboard-core'
9
+ import {
10
+ Button,
11
+ Card,
12
+ CardAction,
13
+ CardContent,
14
+ CardHeader,
15
+ CardTitle,
16
+ DropdownMenu,
17
+ DropdownMenuContent,
18
+ DropdownMenuItem,
19
+ DropdownMenuTrigger,
20
+ FileTypeIcon,
21
+ Pagination,
22
+ RowActions,
23
+ Skeleton,
24
+ Table,
25
+ TableBody,
26
+ TableCell,
27
+ TableHead,
28
+ TableHeader,
29
+ TableRow,
30
+ toastManager,
31
+ useConfirm,
32
+ } from '@spree/dashboard-ui'
33
+ import {
34
+ ChevronDownIcon,
35
+ DownloadIcon,
36
+ FileDownIcon,
37
+ FileIcon,
38
+ PlusIcon,
39
+ UploadIcon,
40
+ } from '@spree/dashboard-ui/icons'
41
+ import { useRef, useState } from 'react'
42
+ import { useTranslation } from 'react-i18next'
43
+ import type { PanelDigitalAsset } from '../api-client'
44
+ import { variantLabel } from './assignable-variants'
45
+ import { DigitalAssetEditSheet } from './digital-asset-edit-sheet'
46
+ import { DigitalAssetProviderSheet } from './digital-asset-provider-sheet'
47
+ import type { PanelVariant } from './product-types'
48
+ import {
49
+ useCreateDigitalAsset,
50
+ useDeleteDigitalAsset,
51
+ useDigitalAssetProviders,
52
+ useDigitalAssets,
53
+ useUpdateDigitalAsset,
54
+ } from './use-digital-assets'
55
+
56
+ export function DigitalAssetsCard({
57
+ productId,
58
+ variants,
59
+ readOnly = false,
60
+ accept,
61
+ notice,
62
+ }: {
63
+ productId?: string
64
+ variants?: PanelVariant[]
65
+ /**
66
+ * Shows the files without any way to change them, for a panel whose host
67
+ * has not opened file uploads to this user — the seller panel when the
68
+ * marketplace keeps digital files to itself. With nothing to show, the
69
+ * card renders nothing at all.
70
+ */
71
+ readOnly?: boolean
72
+ /** The content types the picker offers, when the host restricts them. */
73
+ accept?: string[]
74
+ /**
75
+ * What else a file change does here, shown under the description — the
76
+ * seller panel says that a listing on sale goes back to review.
77
+ */
78
+ notice?: string
79
+ }) {
80
+ const { t } = useTranslation()
81
+ const confirm = useConfirm()
82
+ const { permissions } = usePermissions()
83
+ const canWrite = !readOnly && permissions.can('update', Subject.Product)
84
+ const fileInputRef = useRef<HTMLInputElement>(null)
85
+ const replaceInputRef = useRef<HTMLInputElement>(null)
86
+ // The file whose replacement the picker was opened for.
87
+ const replacingRef = useRef<PanelDigitalAsset | null>(null)
88
+ const [uploading, setUploading] = useState(false)
89
+ const [page, setPage] = useState(1)
90
+ const [editing, setEditing] = useState<PanelDigitalAsset | null>(null)
91
+ // The provider whose settings sheet is open, before its asset is created.
92
+ const [configuring, setConfiguring] = useState<DigitalAssetProvider | null>(null)
93
+
94
+ // Digital files live on private storage: they are only ever served through an
95
+ // authorized, signed link, never from the public bucket.
96
+ const fileUpload = useFileUpload({ visibility: 'private' })
97
+ const { data, isLoading } = useDigitalAssets(productId ?? '', page, Boolean(productId))
98
+ const { data: providersData } = useDigitalAssetProviders(productId ?? '', Boolean(productId))
99
+ // The card toasts upload failures itself, naming the file, so the
100
+ // mutations stay quiet about theirs.
101
+ const createAsset = useCreateDigitalAsset(productId ?? '', { errorMessage: false })
102
+ const deleteAsset = useDeleteDigitalAsset(productId ?? '')
103
+ const updateAsset = useUpdateDigitalAsset(productId ?? '', { errorMessage: false })
104
+
105
+ const assets: PanelDigitalAsset[] = data?.data ?? []
106
+ const meta = data?.meta
107
+ // Across every page, so emptying a later page does not read as no files.
108
+ const hasAssets = (meta?.count ?? assets.length) > 0
109
+ const hasVariants = (variants?.length ?? 0) > 1
110
+ const providers = providersData?.data ?? []
111
+ // Only providers that resolve their own deliverable go in the source menu;
112
+ // the file default is the plain upload button. With none of these, the card
113
+ // keeps its today behaviour — a single "Add file" button.
114
+ const sourceProviders = providers.filter((p) => !p.requires_attachment)
115
+
116
+ // One path for a new file and a replacement: upload to private storage,
117
+ // hand the signed id on, and name the file when either step fails — a
118
+ // refused upload must never be silent.
119
+ async function uploadThen(file: File, persist: (signedId: string) => Promise<unknown>) {
120
+ try {
121
+ const { signedId, previewUrl } = await fileUpload.mutateAsync(file)
122
+ // The preview is for image cards; a download has nothing to show, and
123
+ // an unreleased object URL pins the whole file in memory.
124
+ URL.revokeObjectURL(previewUrl)
125
+ await persist(signedId)
126
+ } catch (err) {
127
+ const message = err instanceof Error ? err.message : t('admin.errors.unexpected')
128
+ toastManager.add({
129
+ type: 'error',
130
+ title: t('admin.digital_assets.upload_failed', { name: file.name, message }),
131
+ })
132
+ }
133
+ }
134
+
135
+ async function handleFiles(files: FileList | File[]) {
136
+ const list = Array.from(files)
137
+ if (list.length === 0 || !productId) return
138
+
139
+ setUploading(true)
140
+ try {
141
+ for (const file of list) {
142
+ await uploadThen(file, (signedId) => createAsset.mutateAsync({ signed_id: signedId }))
143
+ }
144
+ setPage(1)
145
+ } finally {
146
+ setUploading(false)
147
+ }
148
+ }
149
+
150
+ function handleFileInput(event: React.ChangeEvent<HTMLInputElement>) {
151
+ // `event.target.files` is a live FileList — clearing the input's value
152
+ // empties it, so snapshot to a real array before resetting the input
153
+ // (the reset lets the same file be re-selected later).
154
+ const files = Array.from(event.target.files ?? [])
155
+ event.target.value = ''
156
+ if (files.length > 0) void handleFiles(files)
157
+ }
158
+
159
+ // Replacing removes the current file for everyone who bought it, so it is
160
+ // confirmed before the picker opens, like deleting.
161
+ async function openReplace(asset: PanelDigitalAsset) {
162
+ const confirmed = await confirm({
163
+ title: t('admin.digital_assets.replace_title'),
164
+ message: t('admin.digital_assets.replace_description', {
165
+ name: asset.filename ?? t('admin.digital_assets.untitled'),
166
+ }),
167
+ variant: 'destructive',
168
+ })
169
+ if (!confirmed) return
170
+ replacingRef.current = asset
171
+ replaceInputRef.current?.click()
172
+ }
173
+
174
+ // Replaces the file in place, so links buyers already received serve the
175
+ // new one.
176
+ async function handleReplaceInput(event: React.ChangeEvent<HTMLInputElement>) {
177
+ const file = event.target.files?.[0]
178
+ event.target.value = ''
179
+ const asset = replacingRef.current
180
+ replacingRef.current = null
181
+ if (!file || !asset) return
182
+
183
+ setUploading(true)
184
+ try {
185
+ await uploadThen(file, (signedId) =>
186
+ updateAsset.mutateAsync({ id: asset.id, signed_id: signedId }),
187
+ )
188
+ } finally {
189
+ setUploading(false)
190
+ }
191
+ }
192
+
193
+ function handleDrop(event: React.DragEvent) {
194
+ event.preventDefault()
195
+ if (event.dataTransfer.files.length > 0) void handleFiles(event.dataTransfer.files)
196
+ }
197
+
198
+ // A provider with settings opens a sheet to collect them; one without
199
+ // creates its asset immediately, exactly like the file default.
200
+ async function handleAddProvider(provider: DigitalAssetProvider) {
201
+ if (provider.settings_schema.length > 0) {
202
+ setConfiguring(provider)
203
+ return
204
+ }
205
+ try {
206
+ await createAsset.mutateAsync({ provider_type: provider.type })
207
+ setPage(1)
208
+ } catch (err) {
209
+ // The create mutation suppresses its own toast for validation errors.
210
+ const message = err instanceof Error ? err.message : t('admin.errors.unexpected')
211
+ toastManager.add({
212
+ type: 'error',
213
+ title: t('admin.digital_assets.upload_failed', { name: provider.name, message }),
214
+ })
215
+ }
216
+ }
217
+
218
+ async function handleDelete(asset: PanelDigitalAsset) {
219
+ const confirmed = await confirm({
220
+ title: t('admin.digital_assets.delete_title'),
221
+ message: t('admin.digital_assets.delete_description', {
222
+ name: asset.filename ?? t('admin.digital_assets.untitled'),
223
+ }),
224
+ variant: 'destructive',
225
+ })
226
+ if (!confirmed) return
227
+ await deleteAsset.mutateAsync(asset.id)
228
+ if (assets.length === 1 && page > 1) setPage(page - 1)
229
+ }
230
+
231
+ // A blank limit means the store's setting applies — show the number actually
232
+ // in force rather than an empty cell.
233
+ function limitLabel(own: number | null | undefined, effective: number) {
234
+ return own == null ? t('admin.digital_assets.store_default', { count: effective }) : String(own)
235
+ }
236
+
237
+ function assetVariantLabel(asset: PanelDigitalAsset) {
238
+ const variant = variants?.find((v) => v.id === asset.variant_id)
239
+ return variant ? variantLabel(variant) : '—'
240
+ }
241
+
242
+ if (!productId || !getApiClient().digitalAssets) return null
243
+ if (readOnly && !isLoading && !hasAssets) return null
244
+
245
+ return (
246
+ <Card>
247
+ <CardHeader>
248
+ <CardTitle>
249
+ <FileDownIcon className="size-4" />
250
+ {t('admin.digital_assets.title')}
251
+ </CardTitle>
252
+ {/* The header action only appears once files exist — an empty card
253
+ invites the first upload through its dropzone, not a button. */}
254
+ {canWrite && hasAssets && (
255
+ <CardAction>
256
+ {sourceProviders.length === 0 ? (
257
+ <Button
258
+ type="button"
259
+ variant="outline"
260
+ size="sm"
261
+ disabled={uploading}
262
+ onClick={() => fileInputRef.current?.click()}
263
+ >
264
+ <UploadIcon className="mr-2 size-4" />
265
+ {uploading
266
+ ? t('admin.digital_assets.uploading_short')
267
+ : t('admin.digital_assets.upload')}
268
+ </Button>
269
+ ) : (
270
+ <DropdownMenu>
271
+ <DropdownMenuTrigger asChild>
272
+ <Button type="button" variant="outline" size="sm" disabled={uploading}>
273
+ <PlusIcon className="mr-2 size-4" />
274
+ {uploading
275
+ ? t('admin.digital_assets.uploading_short')
276
+ : t('admin.digital_assets.add')}
277
+ <ChevronDownIcon className="ml-2 size-4" />
278
+ </Button>
279
+ </DropdownMenuTrigger>
280
+ <DropdownMenuContent align="end">
281
+ <DropdownMenuItem onClick={() => fileInputRef.current?.click()}>
282
+ <UploadIcon className="mr-2 size-4" />
283
+ {t('admin.digital_assets.source.upload')}
284
+ </DropdownMenuItem>
285
+ {sourceProviders.map((provider) => (
286
+ <DropdownMenuItem
287
+ key={provider.type}
288
+ onClick={() => handleAddProvider(provider)}
289
+ >
290
+ {provider.name}
291
+ </DropdownMenuItem>
292
+ ))}
293
+ </DropdownMenuContent>
294
+ </DropdownMenu>
295
+ )}
296
+ </CardAction>
297
+ )}
298
+ </CardHeader>
299
+
300
+ <CardContent className="flex flex-col gap-4">
301
+ <p className="text-muted-foreground text-sm">{t('admin.digital_assets.description')}</p>
302
+ {notice && canWrite && <p className="text-sm">{notice}</p>}
303
+
304
+ <input
305
+ ref={fileInputRef}
306
+ type="file"
307
+ multiple
308
+ accept={accept?.join(',')}
309
+ className="hidden"
310
+ onChange={handleFileInput}
311
+ />
312
+ <input
313
+ ref={replaceInputRef}
314
+ type="file"
315
+ accept={accept?.join(',')}
316
+ className="hidden"
317
+ onChange={handleReplaceInput}
318
+ />
319
+
320
+ {isLoading ? (
321
+ <Skeleton className="h-24 w-full" />
322
+ ) : !hasAssets && !canWrite ? (
323
+ <p className="text-muted-foreground text-sm">{t('admin.digital_assets.empty')}</p>
324
+ ) : !hasAssets ? (
325
+ <button
326
+ type="button"
327
+ onDrop={handleDrop}
328
+ onDragOver={(e) => e.preventDefault()}
329
+ onClick={() => fileInputRef.current?.click()}
330
+ disabled={uploading}
331
+ className="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border p-6 text-center transition-colors hover:border-foreground/30 disabled:pointer-events-none disabled:opacity-60"
332
+ >
333
+ <FileIcon className="size-8 text-muted-foreground" />
334
+ <p className="text-muted-foreground text-sm">
335
+ {uploading
336
+ ? t('admin.digital_assets.uploading_short')
337
+ : t('admin.digital_assets.drop_hint')}
338
+ </p>
339
+ </button>
340
+ ) : (
341
+ <div className="overflow-hidden rounded-xl border border-border-subtle">
342
+ <Table>
343
+ <TableHeader>
344
+ <TableRow>
345
+ <TableHead>{t('admin.digital_assets.columns.file')}</TableHead>
346
+ {hasVariants && (
347
+ <TableHead>{t('admin.digital_assets.columns.variant')}</TableHead>
348
+ )}
349
+ <TableHead>{t('admin.digital_assets.columns.size')}</TableHead>
350
+ <TableHead>{t('admin.digital_assets.columns.downloads')}</TableHead>
351
+ <TableHead>{t('admin.digital_assets.columns.days')}</TableHead>
352
+ <TableHead />
353
+ </TableRow>
354
+ </TableHeader>
355
+ <TableBody>
356
+ {assets.map((asset) => (
357
+ <TableRow key={asset.id}>
358
+ <TableCell className="font-medium">
359
+ <span className="flex items-center gap-2">
360
+ <FileTypeIcon
361
+ filename={asset.filename}
362
+ contentType={asset.content_type}
363
+ className="text-muted-foreground size-4 shrink-0"
364
+ />
365
+ {asset.download_url ? (
366
+ <a
367
+ href={asset.download_url}
368
+ target="_blank"
369
+ rel="noopener noreferrer"
370
+ className="hover:underline"
371
+ >
372
+ {asset.filename ?? t('admin.digital_assets.untitled')}
373
+ </a>
374
+ ) : (
375
+ (asset.filename ?? t('admin.digital_assets.untitled'))
376
+ )}
377
+ </span>
378
+ </TableCell>
379
+ {hasVariants && (
380
+ <TableCell className="text-muted-foreground">
381
+ {assetVariantLabel(asset)}
382
+ </TableCell>
383
+ )}
384
+ <TableCell className="tabular-nums">
385
+ {asset.byte_size ? formatFileSize(asset.byte_size) : '—'}
386
+ </TableCell>
387
+ <TableCell className="tabular-nums">
388
+ {limitLabel(asset.authorized_clicks, asset.effective_authorized_clicks)}
389
+ </TableCell>
390
+ <TableCell className="tabular-nums">
391
+ {limitLabel(asset.authorized_days, asset.effective_authorized_days)}
392
+ </TableCell>
393
+ <TableCell className="text-right">
394
+ <RowActions
395
+ actions={[
396
+ {
397
+ key: 'download',
398
+ label: t('admin.digital_assets.download'),
399
+ icon: <DownloadIcon className="size-4" />,
400
+ visible: Boolean(asset.download_url),
401
+ onSelect: () => window.open(asset.download_url ?? '', '_blank'),
402
+ },
403
+ {
404
+ key: 'replace',
405
+ label: t('admin.digital_assets.replace'),
406
+ icon: <UploadIcon className="size-4" />,
407
+ // Only a stored file can be swapped; a provider's
408
+ // own deliverable has none to download.
409
+ visible: canWrite && Boolean(asset.download_url),
410
+ disabled: uploading,
411
+ onSelect: () => openReplace(asset),
412
+ },
413
+ { key: 'edit', visible: canWrite, onSelect: () => setEditing(asset) },
414
+ {
415
+ key: 'delete',
416
+ visible: canWrite,
417
+ destructive: true,
418
+ onSelect: () => handleDelete(asset),
419
+ },
420
+ ]}
421
+ />
422
+ </TableCell>
423
+ </TableRow>
424
+ ))}
425
+ </TableBody>
426
+ </Table>
427
+ {meta && <Pagination meta={meta} onPageChange={setPage} />}
428
+ </div>
429
+ )}
430
+ </CardContent>
431
+
432
+ <DigitalAssetEditSheet
433
+ productId={productId}
434
+ asset={editing}
435
+ variants={variants}
436
+ settingsSchema={
437
+ providers.find((p) => p.type === editing?.provider_type)?.settings_schema ?? []
438
+ }
439
+ open={Boolean(editing)}
440
+ onOpenChange={(open) => !open && setEditing(null)}
441
+ />
442
+
443
+ <DigitalAssetProviderSheet
444
+ productId={productId}
445
+ provider={configuring}
446
+ onOpenChange={(open) => !open && setConfiguring(null)}
447
+ onCreated={() => setPage(1)}
448
+ />
449
+ </Card>
450
+ )
451
+ }
@@ -20,9 +20,9 @@ import {
20
20
  SheetTitle,
21
21
  } from '@spree/dashboard-ui'
22
22
  import { CheckIcon } from '@spree/dashboard-ui/icons'
23
- import i18n from 'i18next'
24
23
  import { useCallback, useEffect, useRef } from 'react'
25
24
  import type { UseFormReturn } from 'react-hook-form'
25
+ import { variantLabel } from './assignable-variants'
26
26
  import type { PanelVariant as Variant } from './product-types'
27
27
  import { useSheetSaveHandlers } from './sheet-save-handlers'
28
28
 
@@ -382,14 +382,3 @@ export function MediaEditSheet({
382
382
  </Sheet>
383
383
  )
384
384
  }
385
-
386
- function variantLabel(v: Variant): string {
387
- const named = v as { options_text?: string; name?: string; sku?: string | null }
388
- return (
389
- named.options_text ||
390
- named.name ||
391
- named.sku ||
392
- v.id ||
393
- i18n.t('admin.products.variants.variant_label')
394
- )
395
- }
@@ -0,0 +1,110 @@
1
+ import type { DigitalAssetProviderSettingField } from '@spree/admin-sdk'
2
+ import {
3
+ Field,
4
+ FieldLabel,
5
+ Input,
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ Switch,
12
+ } from '@spree/dashboard-ui'
13
+
14
+ interface Props {
15
+ schema: DigitalAssetProviderSettingField[]
16
+ values: Record<string, unknown>
17
+ onChange: (values: Record<string, unknown>) => void
18
+ }
19
+
20
+ /**
21
+ * Renders one input per provider-declared setting. The provider owns the field
22
+ * list (its `settings_schema`), so this stays generic: a provider ships no
23
+ * dashboard code, it just names its fields and types. Labels come straight from
24
+ * the field key — a provider names a setting for the merchant, not for i18n.
25
+ */
26
+ export function ProviderSettingsFields({ schema, values, onChange }: Props) {
27
+ if (schema.length === 0) return null
28
+
29
+ function set(key: string, value: unknown) {
30
+ onChange({ ...values, [key]: value })
31
+ }
32
+
33
+ return (
34
+ <>
35
+ {schema.map((field) => {
36
+ const value = values[field.key]
37
+ const id = `provider-setting-${field.key}`
38
+
39
+ if (field.type === 'boolean') {
40
+ return (
41
+ <Field key={field.key} className="flex-row items-center justify-between">
42
+ <FieldLabel htmlFor={id}>{field.key}</FieldLabel>
43
+ <Switch
44
+ id={id}
45
+ checked={Boolean(value)}
46
+ onCheckedChange={(checked) => set(field.key, checked)}
47
+ />
48
+ </Field>
49
+ )
50
+ }
51
+
52
+ if (field.type === 'select') {
53
+ return (
54
+ <Field key={field.key}>
55
+ <FieldLabel htmlFor={id}>{field.key}</FieldLabel>
56
+ <Select
57
+ value={value == null ? '' : String(value)}
58
+ onValueChange={(v) => set(field.key, v)}
59
+ >
60
+ <SelectTrigger id={id}>
61
+ <SelectValue />
62
+ </SelectTrigger>
63
+ <SelectContent>
64
+ {(field.in ?? []).map((option) => (
65
+ <SelectItem key={option} value={option}>
66
+ {option}
67
+ </SelectItem>
68
+ ))}
69
+ </SelectContent>
70
+ </Select>
71
+ </Field>
72
+ )
73
+ }
74
+
75
+ return (
76
+ <Field key={field.key}>
77
+ <FieldLabel htmlFor={id}>{field.key}</FieldLabel>
78
+ <Input
79
+ id={id}
80
+ type={field.type === 'number' ? 'number' : 'text'}
81
+ value={value == null ? '' : String(value)}
82
+ onChange={(e) => {
83
+ if (field.type !== 'number') {
84
+ set(field.key, e.target.value)
85
+ return
86
+ }
87
+ // An empty or invalid number input yields NaN, which would
88
+ // serialize to null and silently clear the setting. Keep it
89
+ // unset instead of writing a bogus value.
90
+ const parsed = e.target.valueAsNumber
91
+ set(field.key, Number.isFinite(parsed) ? parsed : undefined)
92
+ }}
93
+ />
94
+ </Field>
95
+ )
96
+ })}
97
+ </>
98
+ )
99
+ }
100
+
101
+ /** Seed field values from a provider's schema defaults (create flow). */
102
+ export function defaultProviderSettings(
103
+ schema: DigitalAssetProviderSettingField[],
104
+ ): Record<string, unknown> {
105
+ const values: Record<string, unknown> = {}
106
+ for (const field of schema) {
107
+ if (field.default !== undefined) values[field.key] = field.default
108
+ }
109
+ return values
110
+ }