@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.
- package/package.json +9 -7
- package/src/api-client.ts +65 -1
- package/src/components/currency-amounts-field.tsx +93 -0
- package/src/index.ts +4 -0
- package/src/lib/currency-amounts.test.ts +147 -0
- package/src/lib/currency-amounts.ts +172 -0
- package/src/locales/ar.json +47 -0
- package/src/locales/de.json +47 -0
- package/src/locales/en.json +47 -0
- package/src/locales/es.json +47 -0
- package/src/locales/fr.json +47 -0
- package/src/locales/pl.json +47 -0
- package/src/locales/zh-CN.json +47 -0
- package/src/products/assignable-variants.ts +35 -0
- package/src/products/digital-asset-edit-sheet.tsx +192 -0
- package/src/products/digital-asset-provider-sheet.tsx +88 -0
- package/src/products/digital-assets-card.tsx +451 -0
- package/src/products/media-edit-sheet.tsx +1 -12
- package/src/products/provider-settings-fields.tsx +110 -0
- package/src/products/use-digital-assets.ts +66 -0
|
@@ -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
|
+
}
|