@spree/dashboard-core 1.0.0-rc.4 → 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 +10 -7
- package/src/api-client.ts +76 -7
- package/src/components/address-form-dialog.tsx +2 -2
- package/src/components/country-combobox.tsx +1 -2
- package/src/components/currency-amounts-field.tsx +93 -0
- package/src/components/currency-select.tsx +6 -7
- package/src/components/file-upload-field.tsx +1 -0
- package/src/components/locale-select.tsx +22 -23
- package/src/components/nav-main.tsx +7 -2
- package/src/components/preferences-form.tsx +2 -8
- package/src/components/settings-sidebar.tsx +8 -1
- package/src/components/slot.tsx +21 -1
- package/src/components/table-toolbar.tsx +102 -39
- package/src/hooks/use-display-name.ts +1 -18
- package/src/hooks/use-file-upload.ts +2 -1
- package/src/hooks/use-import.ts +3 -2
- package/src/index.ts +17 -3
- package/src/lib/currencies.ts +2 -34
- package/src/lib/currency-amounts.test.ts +147 -0
- package/src/lib/currency-amounts.ts +172 -0
- package/src/lib/currency-parts.ts +2 -21
- package/src/lib/filters-to-ransack.ts +4 -1
- package/src/lib/form-fields-registry.ts +23 -1
- package/src/lib/form-mappers.ts +1 -1
- package/src/lib/formatters.ts +6 -10
- package/src/lib/fulfillment-items.ts +11 -0
- package/src/lib/i18n.ts +1 -12
- package/src/lib/iso-codes.ts +9 -26
- package/src/lib/money.ts +9 -99
- package/src/lib/normalize-money.ts +2 -85
- package/src/lib/return-refunds.ts +1 -2
- package/src/lib/route-registry.ts +29 -0
- package/src/lib/settings-nav-registry.ts +6 -1
- package/src/lib/store-config.test.ts +93 -0
- package/src/lib/store-config.ts +76 -0
- package/src/lib/table-registry.ts +16 -5
- package/src/locales/ar.json +51 -0
- package/src/locales/de.json +51 -0
- package/src/locales/en.json +51 -0
- package/src/locales/es.json +51 -0
- package/src/locales/fr.json +51 -0
- package/src/locales/pl.json +51 -0
- package/src/locales/zh-CN.json +51 -0
- package/src/pages/package-types.tsx +50 -11
- package/src/pages/settings-index.tsx +4 -1
- package/src/pages/stock-locations.tsx +31 -6
- package/src/products/assignable-variants.ts +35 -0
- package/src/products/bulk-variants-dialog.tsx +27 -28
- 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 +68 -20
- package/src/products/normalize-quantity.ts +2 -3
- package/src/products/product-bulk-price-editor.tsx +1 -3
- package/src/products/product-form-cards.tsx +67 -37
- package/src/products/product-form-mapping.ts +44 -5
- package/src/products/product-schema.test.ts +8 -0
- package/src/products/product-schema.ts +14 -7
- package/src/products/product-types.ts +1 -1
- package/src/products/provider-settings-fields.tsx +110 -0
- package/src/products/sheet-save-handlers.ts +37 -0
- package/src/products/use-digital-assets.ts +66 -0
- package/src/products/variant-edit-sheet.tsx +21 -11
- package/src/products/variants-section.tsx +4 -1
- package/src/providers/store-provider.tsx +31 -7
- package/src/schemas/package-type.ts +14 -12
- package/src/schemas/stock-location.ts +2 -3
- package/src/tables/stock-locations.tsx +5 -8
- package/src/lib/money.test.ts +0 -63
- package/src/lib/normalize-money.test.ts +0 -67
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { ProductFormValues } from '@spree/dashboard-core'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
extensionFieldNames,
|
|
4
|
+
ImageUploadField,
|
|
5
|
+
parseVideoUrl,
|
|
6
|
+
Slot,
|
|
7
|
+
useTranslation,
|
|
8
|
+
} from '@spree/dashboard-core'
|
|
3
9
|
import {
|
|
4
10
|
Button,
|
|
5
11
|
Field,
|
|
@@ -14,10 +20,11 @@ import {
|
|
|
14
20
|
SheetTitle,
|
|
15
21
|
} from '@spree/dashboard-ui'
|
|
16
22
|
import { CheckIcon } from '@spree/dashboard-ui/icons'
|
|
17
|
-
import i18n from 'i18next'
|
|
18
23
|
import { useCallback, useEffect, useRef } from 'react'
|
|
19
24
|
import type { UseFormReturn } from 'react-hook-form'
|
|
25
|
+
import { variantLabel } from './assignable-variants'
|
|
20
26
|
import type { PanelVariant as Variant } from './product-types'
|
|
27
|
+
import { useSheetSaveHandlers } from './sheet-save-handlers'
|
|
21
28
|
|
|
22
29
|
// The fields this sheet edits. Snapshot, cancel-restore and the type all derive
|
|
23
30
|
// from this one list, so a new editable field can't reach the form and then be
|
|
@@ -39,13 +46,25 @@ interface Props {
|
|
|
39
46
|
variants: Variant[]
|
|
40
47
|
open: boolean
|
|
41
48
|
onOpenChange: (open: boolean) => void
|
|
49
|
+
/** The product form's key — names the slot and the row's extension fields. */
|
|
50
|
+
formKey: string
|
|
51
|
+
productId?: string
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
// Form-backed media editor. The sheet edits `form.media[index]` directly —
|
|
45
55
|
// changes ride the parent product PATCH/POST, no per-asset API calls.
|
|
46
|
-
// Cancel restores a snapshot taken on open; Done
|
|
47
|
-
// commits). Same model as the per-variant
|
|
48
|
-
|
|
56
|
+
// Cancel restores a snapshot taken on open; Done runs the slot widgets' own
|
|
57
|
+
// saves, then closes (parent form Save commits). Same model as the per-variant
|
|
58
|
+
// Sheet.
|
|
59
|
+
export function MediaEditSheet({
|
|
60
|
+
form,
|
|
61
|
+
mediaIndex,
|
|
62
|
+
variants,
|
|
63
|
+
open,
|
|
64
|
+
onOpenChange,
|
|
65
|
+
formKey,
|
|
66
|
+
productId,
|
|
67
|
+
}: Props) {
|
|
49
68
|
const { t } = useTranslation()
|
|
50
69
|
|
|
51
70
|
// Snapshot on open so Cancel can restore. Re-snapshot if mediaIndex changes
|
|
@@ -58,7 +77,11 @@ export function MediaEditSheet({ form, mediaIndex, variants, open, onOpenChange
|
|
|
58
77
|
// freshly-uploaded row that has no id yet we fall back to the current
|
|
59
78
|
// index — uploads don't surface a drag handle so the race is impossible.
|
|
60
79
|
type MediaRow = NonNullable<ProductFormValues['media']>[number]
|
|
61
|
-
type Snapshot = Pick<MediaRow, (typeof EDITED_FIELDS)[number]> & {
|
|
80
|
+
type Snapshot = Pick<MediaRow, (typeof EDITED_FIELDS)[number]> & {
|
|
81
|
+
key: string | null
|
|
82
|
+
// Fields a `<formKey>.media.form_fields` widget edits, restored the same way.
|
|
83
|
+
extensions: Record<string, unknown>
|
|
84
|
+
}
|
|
62
85
|
const snapshotRef = useRef<Snapshot | null>(null)
|
|
63
86
|
const rowKey = useCallback(
|
|
64
87
|
(m: MediaRow): string | null => m.id ?? m.signed_id ?? m.uploadId ?? null,
|
|
@@ -84,8 +107,18 @@ export function MediaEditSheet({ form, mediaIndex, variants, open, onOpenChange
|
|
|
84
107
|
// Copy the array — the snapshot must not alias the row's own value.
|
|
85
108
|
variant_ids: [...(row.variant_ids ?? [])],
|
|
86
109
|
key: rowKey(row),
|
|
110
|
+
extensions: structuredClone(
|
|
111
|
+
Object.fromEntries(
|
|
112
|
+
extensionFieldNames(`${formKey}.media`).map((name) => [
|
|
113
|
+
name,
|
|
114
|
+
(row as Record<string, unknown>)[name],
|
|
115
|
+
]),
|
|
116
|
+
),
|
|
117
|
+
),
|
|
87
118
|
}
|
|
88
|
-
}, [open, mediaIndex, form, rowKey])
|
|
119
|
+
}, [open, mediaIndex, form, rowKey, formKey])
|
|
120
|
+
|
|
121
|
+
const { onSave, saveAll, saving } = useSheetSaveHandlers()
|
|
89
122
|
|
|
90
123
|
const entry = form.watch(`media.${mediaIndex}`)
|
|
91
124
|
if (!entry) return null
|
|
@@ -180,6 +213,8 @@ export function MediaEditSheet({ form, mediaIndex, variants, open, onOpenChange
|
|
|
180
213
|
}
|
|
181
214
|
|
|
182
215
|
const handleCancel = () => {
|
|
216
|
+
// A save already sent can't be called back, so the sheet stays until it settles.
|
|
217
|
+
if (saving) return
|
|
183
218
|
const snap = snapshotRef.current
|
|
184
219
|
if (snap) {
|
|
185
220
|
// Resolve the row by stable key — the media array may have reordered
|
|
@@ -203,6 +238,13 @@ export function MediaEditSheet({ form, mediaIndex, variants, open, onOpenChange
|
|
|
203
238
|
shouldTouch: false,
|
|
204
239
|
})
|
|
205
240
|
}
|
|
241
|
+
for (const [name, value] of Object.entries(snap.extensions)) {
|
|
242
|
+
// Extension names are outside the typed row.
|
|
243
|
+
form.setValue(`media.${targetIndex}.${name}` as any, value, {
|
|
244
|
+
shouldDirty: false,
|
|
245
|
+
shouldTouch: false,
|
|
246
|
+
})
|
|
247
|
+
}
|
|
206
248
|
}
|
|
207
249
|
onOpenChange(false)
|
|
208
250
|
}
|
|
@@ -309,13 +351,30 @@ export function MediaEditSheet({ form, mediaIndex, variants, open, onOpenChange
|
|
|
309
351
|
</div>
|
|
310
352
|
</Field>
|
|
311
353
|
)}
|
|
354
|
+
|
|
355
|
+
<Slot
|
|
356
|
+
name={`${formKey}.media.form_fields`}
|
|
357
|
+
context={{
|
|
358
|
+
productId,
|
|
359
|
+
media: entry,
|
|
360
|
+
mediaIndex,
|
|
361
|
+
fieldPrefix: `media.${mediaIndex}`,
|
|
362
|
+
onSave,
|
|
363
|
+
}}
|
|
364
|
+
/>
|
|
312
365
|
</div>
|
|
313
366
|
|
|
314
367
|
<SheetFooter>
|
|
315
|
-
<Button type="button" variant="outline" onClick={handleCancel}>
|
|
368
|
+
<Button type="button" variant="outline" disabled={saving} onClick={handleCancel}>
|
|
316
369
|
{t('admin.actions.cancel')}
|
|
317
370
|
</Button>
|
|
318
|
-
<Button
|
|
371
|
+
<Button
|
|
372
|
+
type="button"
|
|
373
|
+
disabled={saving}
|
|
374
|
+
onClick={async () => {
|
|
375
|
+
if (await saveAll()) onOpenChange(false)
|
|
376
|
+
}}
|
|
377
|
+
>
|
|
319
378
|
{t('admin.actions.done')}
|
|
320
379
|
</Button>
|
|
321
380
|
</SheetFooter>
|
|
@@ -323,14 +382,3 @@ export function MediaEditSheet({ form, mediaIndex, variants, open, onOpenChange
|
|
|
323
382
|
</Sheet>
|
|
324
383
|
)
|
|
325
384
|
}
|
|
326
|
-
|
|
327
|
-
function variantLabel(v: Variant): string {
|
|
328
|
-
const named = v as { options_text?: string; name?: string; sku?: string | null }
|
|
329
|
-
return (
|
|
330
|
-
named.options_text ||
|
|
331
|
-
named.name ||
|
|
332
|
-
named.sku ||
|
|
333
|
-
v.id ||
|
|
334
|
-
i18n.t('admin.products.variants.variant_label')
|
|
335
|
-
)
|
|
336
|
-
}
|
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
// `Spree::Variant`'s own validations so a merchant hears about a bad value
|
|
3
3
|
// while typing rather than on save.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
export const PURCHASE_UNITS = ['unit', 'carton'] as const
|
|
5
|
+
import { PURCHASE_UNITS, type PurchaseUnit } from '@spree/core'
|
|
7
6
|
|
|
8
|
-
export type PurchaseUnit
|
|
7
|
+
export { PURCHASE_UNITS, type PurchaseUnit }
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
10
|
* Reads a quantity rule from a number input. Blank means the rule is unset,
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type CurrencyCode, currencyParts, normalizeMoneyInput } from '@spree/core'
|
|
2
2
|
import type { ProductFormValues, VariantPriceFormValues } from '@spree/dashboard-core'
|
|
3
3
|
import { type BulkPriceRow, BulkPriceTable } from '@spree/dashboard-ui'
|
|
4
4
|
import { useCallback, useMemo } from 'react'
|
|
5
5
|
import { type UseFormReturn, useWatch } from 'react-hook-form'
|
|
6
6
|
import { useTranslation } from 'react-i18next'
|
|
7
7
|
import { useMoneyLocale } from '../hooks/use-money-locale'
|
|
8
|
-
import { currencyParts } from '../lib/currency-parts'
|
|
9
|
-
import { normalizeMoneyInput } from '../lib/normalize-money'
|
|
10
8
|
import { composeOptionsText } from '../products/variants-matrix'
|
|
11
9
|
import { useFormOptionTypes as useOptionTypes } from './use-product-form-data'
|
|
12
10
|
|