@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
|
@@ -13,13 +13,14 @@ import {
|
|
|
13
13
|
useSortable,
|
|
14
14
|
} from '@dnd-kit/sortable'
|
|
15
15
|
import { CSS } from '@dnd-kit/utilities'
|
|
16
|
-
import type { CurrencyCode } from '@spree/
|
|
16
|
+
import type { CurrencyCode } from '@spree/core'
|
|
17
17
|
import type { MediaType, ProductFormValues } from '@spree/dashboard-core'
|
|
18
18
|
import {
|
|
19
19
|
getApiClient,
|
|
20
20
|
MediaPickerSheet,
|
|
21
21
|
parseVideoUrl,
|
|
22
22
|
ResourceMultiAutocomplete,
|
|
23
|
+
Slot,
|
|
23
24
|
TagCombobox,
|
|
24
25
|
useFileUpload,
|
|
25
26
|
useOptionalStore,
|
|
@@ -86,7 +87,11 @@ import { InventorySection } from './inventory-section'
|
|
|
86
87
|
import { MediaEditSheet } from './media-edit-sheet'
|
|
87
88
|
import { ProductBulkPriceEditor } from './product-bulk-price-editor'
|
|
88
89
|
import { variantHasTaxCategoryOverride } from './product-form-mapping'
|
|
89
|
-
import
|
|
90
|
+
import {
|
|
91
|
+
ASSIGNABLE_PRODUCT_STATUSES,
|
|
92
|
+
type MediaFormValues,
|
|
93
|
+
type VariantFormValues,
|
|
94
|
+
} from './product-schema'
|
|
90
95
|
import type {
|
|
91
96
|
PanelMedia as Media,
|
|
92
97
|
PanelProduct as Product,
|
|
@@ -175,8 +180,12 @@ export function GeneralCard({
|
|
|
175
180
|
// Variants (just a passthrough so the page composition reads top-down)
|
|
176
181
|
// ---------------------------------------------------------------------------
|
|
177
182
|
|
|
178
|
-
export function VariantsCard({
|
|
179
|
-
|
|
183
|
+
export function VariantsCard({
|
|
184
|
+
form,
|
|
185
|
+
seedFromType,
|
|
186
|
+
formKey = 'product',
|
|
187
|
+
}: FormCardProps & { seedFromType?: boolean; formKey?: string }) {
|
|
188
|
+
return <VariantsSection form={form} seedFromType={seedFromType} formKey={formKey} />
|
|
180
189
|
}
|
|
181
190
|
|
|
182
191
|
// ---------------------------------------------------------------------------
|
|
@@ -260,6 +269,13 @@ export function PricesCard({
|
|
|
260
269
|
// merchant sees where uploads will go but can't trigger them.
|
|
261
270
|
// ---------------------------------------------------------------------------
|
|
262
271
|
|
|
272
|
+
/**
|
|
273
|
+
* A media entry appended to the gallery without an upload: a `signed_id` from
|
|
274
|
+
* a finished upload, a library `source_media_id`, or an external video link.
|
|
275
|
+
* `previewUrl` is what the tile shows until the product is saved.
|
|
276
|
+
*/
|
|
277
|
+
export type NewMediaEntry = Omit<MediaFormValues, 'position' | 'uploadId'>
|
|
278
|
+
|
|
263
279
|
interface PendingUpload {
|
|
264
280
|
id: string
|
|
265
281
|
file: File
|
|
@@ -289,8 +305,11 @@ export function MediaCard({
|
|
|
289
305
|
productId,
|
|
290
306
|
variants,
|
|
291
307
|
form,
|
|
308
|
+
formKey = 'product',
|
|
292
309
|
}: {
|
|
293
310
|
productId?: string
|
|
311
|
+
/** The product form's key — names the media slots and the rows' extension fields. */
|
|
312
|
+
formKey?: string
|
|
294
313
|
// The MediaEditSheet's "assign to variant" pill row needs the list of
|
|
295
314
|
// server-persisted variants — only those have an id that can ride the
|
|
296
315
|
// PATCH's media[].variant_ids. Form-state variants without a server id
|
|
@@ -413,20 +432,21 @@ export function MediaCard({
|
|
|
413
432
|
|
|
414
433
|
// An external video has no file to upload — it goes straight into form state
|
|
415
434
|
// with its link, and the provider's own still stands in as the preview.
|
|
416
|
-
|
|
417
|
-
|
|
435
|
+
// Appends entries that need no upload of their own — a video link, a
|
|
436
|
+
// library pick, or whatever a `<formKey>.media.actions` widget hands over —
|
|
437
|
+
// positioned after the existing ones. They save with the product.
|
|
438
|
+
const appendMedia = useCallback(
|
|
439
|
+
(entries: NewMediaEntry[]) => {
|
|
418
440
|
const current = form.getValues('media') ?? []
|
|
419
441
|
form.setValue(
|
|
420
442
|
'media',
|
|
421
443
|
[
|
|
422
444
|
...current,
|
|
423
|
-
{
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
position: current.length + 1,
|
|
427
|
-
previewUrl: parseVideoUrl(url)?.thumbnailUrl ?? undefined,
|
|
445
|
+
...entries.map((entry, index) => ({
|
|
446
|
+
...entry,
|
|
447
|
+
position: current.length + index + 1,
|
|
428
448
|
uploadId: crypto.randomUUID(),
|
|
429
|
-
},
|
|
449
|
+
})),
|
|
430
450
|
],
|
|
431
451
|
{ shouldDirty: true },
|
|
432
452
|
)
|
|
@@ -434,31 +454,34 @@ export function MediaCard({
|
|
|
434
454
|
[form],
|
|
435
455
|
)
|
|
436
456
|
|
|
457
|
+
const handleAddVideo = useCallback(
|
|
458
|
+
(url: string) =>
|
|
459
|
+
appendMedia([
|
|
460
|
+
{
|
|
461
|
+
media_type: 'external_video',
|
|
462
|
+
external_video_url: url,
|
|
463
|
+
previewUrl: parseVideoUrl(url)?.thumbnailUrl ?? undefined,
|
|
464
|
+
},
|
|
465
|
+
]),
|
|
466
|
+
[appendMedia],
|
|
467
|
+
)
|
|
468
|
+
|
|
437
469
|
// Files picked from the library become ordinary form entries carrying the
|
|
438
470
|
// source's id. The product save places them, sharing the file rather than
|
|
439
471
|
// uploading a second copy.
|
|
440
472
|
const handleAddFromLibrary = useCallback(
|
|
441
|
-
(picked: Media[]) =>
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
videoUrl: media.video_url ?? null,
|
|
454
|
-
posterUrl: media.poster_url ?? null,
|
|
455
|
-
uploadId: crypto.randomUUID(),
|
|
456
|
-
})),
|
|
457
|
-
],
|
|
458
|
-
{ shouldDirty: true },
|
|
459
|
-
)
|
|
460
|
-
},
|
|
461
|
-
[form],
|
|
473
|
+
(picked: Media[]) =>
|
|
474
|
+
appendMedia(
|
|
475
|
+
picked.map((media) => ({
|
|
476
|
+
source_media_id: media.id,
|
|
477
|
+
alt: media.alt ?? undefined,
|
|
478
|
+
media_type: (media.media_type ?? 'image') as MediaType,
|
|
479
|
+
previewUrl: media.small_url ?? media.original_url ?? undefined,
|
|
480
|
+
videoUrl: media.video_url ?? null,
|
|
481
|
+
posterUrl: media.poster_url ?? null,
|
|
482
|
+
})),
|
|
483
|
+
),
|
|
484
|
+
[appendMedia],
|
|
462
485
|
)
|
|
463
486
|
|
|
464
487
|
const handleDelete = useCallback(
|
|
@@ -620,6 +643,11 @@ export function MediaCard({
|
|
|
620
643
|
<FilmIcon />
|
|
621
644
|
{t('admin.products.media.add_video')}
|
|
622
645
|
</Button>
|
|
646
|
+
|
|
647
|
+
<Slot
|
|
648
|
+
name={`${formKey}.media.actions`}
|
|
649
|
+
context={{ productId, addMedia: appendMedia }}
|
|
650
|
+
/>
|
|
623
651
|
</div>
|
|
624
652
|
</CardContent>
|
|
625
653
|
</Card>
|
|
@@ -649,6 +677,8 @@ export function MediaCard({
|
|
|
649
677
|
onOpenChange={(open) => {
|
|
650
678
|
if (!open) setEditingIndex(null)
|
|
651
679
|
}}
|
|
680
|
+
formKey={formKey}
|
|
681
|
+
productId={productId}
|
|
652
682
|
/>
|
|
653
683
|
)}
|
|
654
684
|
</>
|
|
@@ -889,8 +919,6 @@ export function SEOCard({ form, product }: FormCardProps & { product?: Product }
|
|
|
889
919
|
// Status
|
|
890
920
|
// ---------------------------------------------------------------------------
|
|
891
921
|
|
|
892
|
-
const ASSIGNABLE_STATUSES = ['draft', 'active', 'archived']
|
|
893
|
-
|
|
894
922
|
export function StatusCard({
|
|
895
923
|
form,
|
|
896
924
|
reviewActions,
|
|
@@ -915,9 +943,11 @@ export function StatusCard({
|
|
|
915
943
|
// made, so the operator can move the product on from here like any other —
|
|
916
944
|
// overriding their own refusal is not a review action, and leaving the
|
|
917
945
|
// picker disabled there was a dead end with no way out.
|
|
918
|
-
const inReview = Boolean(status) && !
|
|
946
|
+
const inReview = Boolean(status) && !ASSIGNABLE_PRODUCT_STATUSES.includes(status as string)
|
|
919
947
|
const awaitingDecision = status === 'proposed'
|
|
920
|
-
const values = inReview
|
|
948
|
+
const values = inReview
|
|
949
|
+
? [status as string, ...ASSIGNABLE_PRODUCT_STATUSES]
|
|
950
|
+
: ASSIGNABLE_PRODUCT_STATUSES
|
|
921
951
|
|
|
922
952
|
return (
|
|
923
953
|
<SharedStatusCard<ProductFormValues>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { extensionFormValues, extensionRowValues } from '../lib/form-fields-registry'
|
|
1
2
|
import { i18n } from '../lib/i18n'
|
|
2
3
|
import { normalizeQuantityRule } from './normalize-quantity'
|
|
3
4
|
import type { MediaType, ProductFormValues, VariantFormValues } from './product-schema'
|
|
@@ -11,8 +12,18 @@ import type { PanelMedia, PanelProduct, PanelVariant } from './product-types'
|
|
|
11
12
|
* this mapping would be two ways to disagree.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
/**
|
|
16
|
+
* `formKey` is the product form's key (`'product'`, or `'seller.product'` in
|
|
17
|
+
* the seller panel); the rows' extension fields are registered under
|
|
18
|
+
* `${formKey}.variant` and `${formKey}.media`.
|
|
19
|
+
*/
|
|
20
|
+
export function variantToFormValues(
|
|
21
|
+
variant: PanelVariant,
|
|
22
|
+
position: number,
|
|
23
|
+
formKey = 'product',
|
|
24
|
+
): VariantFormValues {
|
|
15
25
|
return {
|
|
26
|
+
...extensionFormValues(`${formKey}.variant`, variant),
|
|
16
27
|
id: variant.id,
|
|
17
28
|
sku: variant.sku ?? null,
|
|
18
29
|
barcode: variant.barcode ?? null,
|
|
@@ -76,8 +87,9 @@ export function variantToFormValues(variant: PanelVariant, position: number): Va
|
|
|
76
87
|
// late paint) so a new media field can't reach one and miss the other.
|
|
77
88
|
// Takes the generated SDK type rather than a hand-listed shape, so a field
|
|
78
89
|
// added to the serializer can't quietly go missing here.
|
|
79
|
-
export function mediaToFormValues(media: PanelMedia, index: number) {
|
|
90
|
+
export function mediaToFormValues(media: PanelMedia, index: number, formKey = 'product') {
|
|
80
91
|
return {
|
|
92
|
+
...extensionFormValues(`${formKey}.media`, media),
|
|
81
93
|
id: media.id,
|
|
82
94
|
alt: media.alt ?? null,
|
|
83
95
|
position: media.position ?? index + 1,
|
|
@@ -108,6 +120,7 @@ export function productToFormValues(
|
|
|
108
120
|
// captures it atomically instead of via a follow-up setValue that races
|
|
109
121
|
// with the merchant's unsaved edits.
|
|
110
122
|
media?: PanelMedia[],
|
|
123
|
+
formKey = 'product',
|
|
111
124
|
): ProductFormValues {
|
|
112
125
|
const hasVariants = (product.variant_count ?? 0) > 0
|
|
113
126
|
const variantSource = hasVariants
|
|
@@ -140,14 +153,14 @@ export function productToFormValues(
|
|
|
140
153
|
meta_title: product.meta_title ?? '',
|
|
141
154
|
meta_description: product.meta_description ?? '',
|
|
142
155
|
slug: product.slug ?? '',
|
|
143
|
-
variants: variantSource.map((v, i) => variantToFormValues(v, i)),
|
|
156
|
+
variants: variantSource.map((v, i) => variantToFormValues(v, i, formKey)),
|
|
144
157
|
custom_fields:
|
|
145
158
|
product.custom_fields?.map((cf) => ({
|
|
146
159
|
id: cf.id,
|
|
147
160
|
custom_field_definition_id: cf.custom_field_definition_id,
|
|
148
161
|
value: cf.value,
|
|
149
162
|
})) ?? [],
|
|
150
|
-
media: media?.map(mediaToFormValues) ?? [],
|
|
163
|
+
media: media?.map((m, i) => mediaToFormValues(m, i, formKey)) ?? [],
|
|
151
164
|
product_publications: (product.product_publications ?? []).map((l) => ({
|
|
152
165
|
id: l.id,
|
|
153
166
|
channel_id: l.channel_id,
|
|
@@ -157,6 +170,31 @@ export function productToFormValues(
|
|
|
157
170
|
}
|
|
158
171
|
}
|
|
159
172
|
|
|
173
|
+
/**
|
|
174
|
+
* The submitted values with each variant and media row's extension fields put
|
|
175
|
+
* back from live form state. The Zod parse behind a submit strips keys the
|
|
176
|
+
* schema doesn't know from every row; it keeps the rows' order, so row `i` of
|
|
177
|
+
* the parse is row `i` of the form. Call it first in a submit handler, before
|
|
178
|
+
* rows are filtered or reordered.
|
|
179
|
+
*/
|
|
180
|
+
export function withExtensionRowValues(
|
|
181
|
+
formKey: string,
|
|
182
|
+
values: ProductFormValues,
|
|
183
|
+
raw: Partial<ProductFormValues>,
|
|
184
|
+
): ProductFormValues {
|
|
185
|
+
return {
|
|
186
|
+
...values,
|
|
187
|
+
variants: values.variants?.map((variant, index) => ({
|
|
188
|
+
...variant,
|
|
189
|
+
...extensionRowValues(`${formKey}.variant`, raw.variants?.[index]),
|
|
190
|
+
})),
|
|
191
|
+
media: values.media?.map((media, index) => ({
|
|
192
|
+
...media,
|
|
193
|
+
...extensionRowValues(`${formKey}.media`, raw.media?.[index]),
|
|
194
|
+
})),
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
160
198
|
// Strip UI-only fields (stock_location_name) and undefined entries so the
|
|
161
199
|
// PATCH body matches the Admin API VariantUpdateParams shape exactly. The
|
|
162
200
|
// Spree::Product#variants= setter reconciles by id, creates new entries,
|
|
@@ -187,13 +225,14 @@ export function prepareVariantsTaxForProductSave(
|
|
|
187
225
|
})
|
|
188
226
|
}
|
|
189
227
|
|
|
190
|
-
export function variantToWirePayload(v: VariantFormValues, index: number) {
|
|
228
|
+
export function variantToWirePayload(v: VariantFormValues, index: number, formKey = 'product') {
|
|
191
229
|
// DB columns `sku` and `weight` are NOT NULL with defaults ("", 0.0).
|
|
192
230
|
// The other scalar fields (barcode, dimensions, weight_unit,
|
|
193
231
|
// dimensions_unit, tax_category_id) ARE nullable — those we always send
|
|
194
232
|
// even when null so the merchant can clear them. NOT-NULL fields fall
|
|
195
233
|
// back to their schema defaults when blank.
|
|
196
234
|
const payload: Record<string, unknown> = {
|
|
235
|
+
...extensionRowValues(`${formKey}.variant`, v),
|
|
197
236
|
position: index + 1,
|
|
198
237
|
options: v.options,
|
|
199
238
|
sku: v.sku ?? '',
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { productFormSchema } from './product-schema'
|
|
3
|
+
|
|
4
|
+
describe('productFormSchema status', () => {
|
|
5
|
+
it('accepts a status an extension added, so the form still saves', () => {
|
|
6
|
+
expect(productFormSchema.shape.status.safeParse('pending_compliance').success).toBe(true)
|
|
7
|
+
})
|
|
8
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { MEDIA_TYPES, type MediaType, PRODUCT_STATUSES, subsetOf } from '@spree/core'
|
|
1
2
|
import { requiredMessage } from '@spree/dashboard-ui'
|
|
2
3
|
import i18n from 'i18next'
|
|
3
4
|
import { z } from 'zod/v4'
|
|
@@ -103,9 +104,7 @@ export type CustomFieldFormValues = z.infer<typeof customFieldFormSchema>
|
|
|
103
104
|
// `source_media_id` (a file picked from the library). All can hold edits to
|
|
104
105
|
// alt, position, and variant_ids that the parent product PATCH/POST ships
|
|
105
106
|
// inline. previewUrl + uploadId are UI-only; stripped at submit.
|
|
106
|
-
export
|
|
107
|
-
|
|
108
|
-
export type MediaType = (typeof MEDIA_TYPES)[number]
|
|
107
|
+
export { MEDIA_TYPES, type MediaType }
|
|
109
108
|
|
|
110
109
|
export const mediaFormSchema = z
|
|
111
110
|
.object({
|
|
@@ -169,6 +168,13 @@ export const productPublicationFormSchema = z.object({
|
|
|
169
168
|
|
|
170
169
|
export type ProductPublicationFormValues = z.infer<typeof productPublicationFormSchema>
|
|
171
170
|
|
|
171
|
+
/** Statuses a merchant sets by hand; the rest come from the review workflow. */
|
|
172
|
+
export const ASSIGNABLE_PRODUCT_STATUSES = subsetOf(PRODUCT_STATUSES, [
|
|
173
|
+
'draft',
|
|
174
|
+
'active',
|
|
175
|
+
'archived',
|
|
176
|
+
])
|
|
177
|
+
|
|
172
178
|
export const productFormSchema = z.object({
|
|
173
179
|
// General
|
|
174
180
|
name: z.string().min(1, { error: requiredMessage('name') }),
|
|
@@ -176,10 +182,11 @@ export const productFormSchema = z.object({
|
|
|
176
182
|
|
|
177
183
|
// Status
|
|
178
184
|
// Every status the API may answer, not just the ones a form sets. A
|
|
179
|
-
// marketplace product also comes back `proposed` or `rejected`,
|
|
180
|
-
//
|
|
181
|
-
// field
|
|
182
|
-
|
|
185
|
+
// marketplace product also comes back `proposed` or `rejected`, an extension
|
|
186
|
+
// may add statuses of its own, and a schema that rejects any of those makes
|
|
187
|
+
// the whole form unsavable rather than flagging a field
|
|
188
|
+
// (docs/plans/6.0-seller-product-submission.md).
|
|
189
|
+
status: z.string().optional(),
|
|
183
190
|
|
|
184
191
|
// Categorization
|
|
185
192
|
category_ids: z.array(z.string()).optional(),
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/** Persists a slot widget's own edits; resolves `false` to keep the sheet open. */
|
|
4
|
+
export type SheetSaveHandler = () => Promise<boolean>
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Lets widgets in a product-page sheet's slot save their own records when the
|
|
8
|
+
* sheet's Done is pressed. The sheet's fields ride the product's Save, but a
|
|
9
|
+
* widget writing a different record (a variant's custom fields) has no other
|
|
10
|
+
* moment that matches what the merchant expects: Done keeps, Cancel drops.
|
|
11
|
+
*/
|
|
12
|
+
export function useSheetSaveHandlers() {
|
|
13
|
+
const handlers = useRef(new Set<SheetSaveHandler>())
|
|
14
|
+
const [saving, setSaving] = useState(false)
|
|
15
|
+
|
|
16
|
+
/** Registers a handler; the returned function unregisters it. */
|
|
17
|
+
const onSave = useCallback((handler: SheetSaveHandler) => {
|
|
18
|
+
handlers.current.add(handler)
|
|
19
|
+
return () => {
|
|
20
|
+
handlers.current.delete(handler)
|
|
21
|
+
}
|
|
22
|
+
}, [])
|
|
23
|
+
|
|
24
|
+
// A widget that throws keeps the sheet open like one that resolves `false`,
|
|
25
|
+
// rather than escaping as an unhandled rejection from the Done click.
|
|
26
|
+
const saveAll = useCallback(async () => {
|
|
27
|
+
setSaving(true)
|
|
28
|
+
try {
|
|
29
|
+
const results = await Promise.allSettled([...handlers.current].map((handler) => handler()))
|
|
30
|
+
return results.every((result) => result.status === 'fulfilled' && result.value)
|
|
31
|
+
} finally {
|
|
32
|
+
setSaving(false)
|
|
33
|
+
}
|
|
34
|
+
}, [])
|
|
35
|
+
|
|
36
|
+
return { onSave, saveAll, saving }
|
|
37
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getApiClient, i18n, useResourceKey, useResourceMutation } from '@spree/dashboard-core'
|
|
2
|
+
import { useQuery } from '@tanstack/react-query'
|
|
3
|
+
import type { PanelDigitalAssetParams } from '../api-client'
|
|
4
|
+
|
|
5
|
+
// The logical invalidation key (tenant id auto-injected by useResourceMutation).
|
|
6
|
+
const digitalAssetsKey = (productId: string) => ['products', productId, 'digital-assets']
|
|
7
|
+
|
|
8
|
+
function digitalAssetsClient() {
|
|
9
|
+
const client = getApiClient().digitalAssets
|
|
10
|
+
if (!client) throw new Error('This panel cannot manage digital files.')
|
|
11
|
+
return client
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useDigitalAssets(productId: string, page = 1, enabled = true) {
|
|
15
|
+
return useQuery({
|
|
16
|
+
queryKey: useResourceKey('products', productId, 'digital-assets', page),
|
|
17
|
+
queryFn: () => digitalAssetsClient().list(productId, { page, limit: 25 }),
|
|
18
|
+
enabled: enabled && Boolean(productId) && Boolean(getApiClient().digitalAssets),
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The selectable sources for a new asset. Almost always just the File default,
|
|
23
|
+
// so a host with no extra provider gets a one-entry list and the card keeps its
|
|
24
|
+
// plain "Add file" button.
|
|
25
|
+
export function useDigitalAssetProviders(productId: string, enabled = true) {
|
|
26
|
+
const providers = getApiClient().digitalAssets?.providers
|
|
27
|
+
return useQuery({
|
|
28
|
+
// Not keyed by product: the registry is the same for all of them.
|
|
29
|
+
queryKey: useResourceKey('digital-asset-providers'),
|
|
30
|
+
queryFn: () => providers!(productId),
|
|
31
|
+
enabled: enabled && Boolean(productId) && Boolean(providers),
|
|
32
|
+
staleTime: Number.POSITIVE_INFINITY, // the registry doesn't change at runtime
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `errorMessage: false` hands every failure to the caller — the card names
|
|
38
|
+
* the file in its own toast, and two toasts for one refusal is noise.
|
|
39
|
+
*/
|
|
40
|
+
export function useCreateDigitalAsset(productId: string, options: { errorMessage?: false } = {}) {
|
|
41
|
+
return useResourceMutation({
|
|
42
|
+
mutationFn: (params: PanelDigitalAssetParams) =>
|
|
43
|
+
digitalAssetsClient().create(productId, params),
|
|
44
|
+
invalidate: [digitalAssetsKey(productId)],
|
|
45
|
+
successMessage: i18n.t('admin.messages.digital_asset_saved'),
|
|
46
|
+
...options,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useUpdateDigitalAsset(productId: string, options: { errorMessage?: false } = {}) {
|
|
51
|
+
return useResourceMutation({
|
|
52
|
+
mutationFn: ({ id, ...params }: PanelDigitalAssetParams & { id: string }) =>
|
|
53
|
+
digitalAssetsClient().update(productId, id, params),
|
|
54
|
+
invalidate: [digitalAssetsKey(productId)],
|
|
55
|
+
successMessage: i18n.t('admin.messages.digital_asset_saved'),
|
|
56
|
+
...options,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function useDeleteDigitalAsset(productId: string) {
|
|
61
|
+
return useResourceMutation({
|
|
62
|
+
mutationFn: (id: string) => digitalAssetsClient().delete(productId, id),
|
|
63
|
+
invalidate: [digitalAssetsKey(productId)],
|
|
64
|
+
successMessage: i18n.t('admin.messages.digital_asset_removed'),
|
|
65
|
+
})
|
|
66
|
+
}
|