@spree/dashboard-core 6.0.2 → 6.0.4
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/dist/vite/index.js +81 -5
- package/package.json +24 -24
- package/src/api-client.ts +7 -1
- package/src/client.ts +2 -0
- package/src/components/address-form-dialog.tsx +75 -28
- package/src/components/app-shell.tsx +5 -4
- package/src/components/app-sidebar.tsx +2 -1
- package/src/components/bulk-action-bar.tsx +6 -5
- package/src/components/country-state-fields.tsx +3 -0
- package/src/components/currency-select.tsx +6 -4
- package/src/components/developer-inspector-drawer.tsx +322 -0
- package/src/components/file-upload-field.tsx +64 -37
- package/src/components/image-upload-field.tsx +4 -0
- package/src/components/import-wizard-dialog.tsx +14 -12
- package/src/components/invitation-acceptance.tsx +15 -11
- package/src/components/media-editor-dialog.tsx +131 -0
- package/src/components/media-picker-dialog.tsx +866 -0
- package/src/components/media-picker-sheet.tsx +13 -300
- package/src/components/mobile-top-bar.tsx +1 -1
- package/src/components/nav-main.tsx +13 -2
- package/src/components/page-header.tsx +57 -13
- package/src/components/page-tabs.tsx +1 -1
- package/src/components/product-stock-summary.tsx +58 -0
- package/src/components/read-row.tsx +38 -0
- package/src/components/resource-combobox.tsx +1 -1
- package/src/components/resource-image-field.tsx +150 -0
- package/src/components/resource-picker-sheet.tsx +2 -2
- package/src/components/resource-table.tsx +161 -40
- package/src/components/section-heading.tsx +2 -2
- package/src/components/settings-container.tsx +19 -0
- package/src/components/settings-fields.tsx +165 -0
- package/src/components/settings-section.tsx +46 -0
- package/src/components/settings-sidebar.tsx +35 -18
- package/src/components/sheet-top-bar.tsx +7 -8
- package/src/components/sidebar-search.tsx +11 -12
- package/src/components/sidebar-user.tsx +39 -44
- package/src/components/skip-link.tsx +1 -1
- package/src/components/store-switcher.tsx +1 -1
- package/src/components/table-toolbar.tsx +194 -331
- package/src/components/timezone-combobox.tsx +119 -0
- package/src/components/top-bar-breadcrumbs.tsx +1 -1
- package/src/hooks/use-stock-locations.ts +6 -2
- package/src/hooks/use-table-density.ts +50 -0
- package/src/index.ts +20 -2
- package/src/lib/curl.test.ts +18 -0
- package/src/lib/curl.ts +28 -0
- package/src/lib/developer-inspector.test.ts +169 -0
- package/src/lib/developer-inspector.ts +134 -0
- package/src/lib/docs.ts +3 -2
- package/src/lib/fulfillment-items.test.ts +51 -0
- package/src/lib/fulfillment-items.ts +53 -0
- package/src/lib/request-snippets.ts +41 -0
- package/src/lib/table-registry.ts +13 -6
- package/src/lib/timestamp-columns.tsx +1 -1
- package/src/locales/ar.json +128 -14
- package/src/locales/de.json +128 -14
- package/src/locales/en.json +128 -14
- package/src/locales/es.json +128 -14
- package/src/locales/fr.json +128 -14
- package/src/locales/ja.json +128 -14
- package/src/locales/pl.json +130 -14
- package/src/locales/vi.json +128 -14
- package/src/locales/zh-CN.json +128 -14
- package/src/pages/settings-index.tsx +79 -48
- package/src/pages/stock-locations.tsx +14 -6
- package/src/products/digital-asset-edit-sheet.tsx +1 -1
- package/src/products/digital-asset-provider-sheet.tsx +1 -1
- package/src/products/digital-assets-card.tsx +7 -7
- package/src/products/inventory-section.tsx +2 -2
- package/src/products/media-edit-sheet.tsx +136 -133
- package/src/products/product-form-cards.tsx +204 -73
- package/src/products/product-form-mapping.test.ts +42 -1
- package/src/products/product-form-mapping.ts +20 -0
- package/src/products/product-schema.ts +8 -0
- package/src/products/product-types.ts +2 -0
- package/src/products/use-product-form-data.ts +30 -3
- package/src/products/variant-edit-sheet.tsx +3 -3
- package/src/products/variants-options-builder.tsx +13 -8
- package/src/products/variants-section.tsx +196 -4
- package/src/providers/auth-provider.tsx +2 -0
- package/src/providers/developer-inspector-provider.tsx +145 -0
- package/src/providers/json-link-resolver-provider.tsx +27 -0
- package/src/sellers/seller-schema.ts +107 -0
- package/src/tables/package-types.tsx +3 -3
- package/src/tables/stock-locations.tsx +15 -14
- package/src/virtual.d.ts +4 -0
- package/src/vite/index.ts +103 -5
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { useCallback } from 'react'
|
|
2
3
|
import type {
|
|
3
4
|
PanelCollection,
|
|
4
5
|
PanelDeliveryProfile,
|
|
@@ -162,9 +163,35 @@ export function useFormCartonPackageTypes() {
|
|
|
162
163
|
}
|
|
163
164
|
|
|
164
165
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
|
|
166
|
+
* Deletes the media a product form marked for removal, after the product
|
|
167
|
+
* itself saved. Resolves to how many deletes failed, so the form can say so.
|
|
168
|
+
*/
|
|
169
|
+
export function useCommitRemovedProductMedia(productId: string) {
|
|
170
|
+
const client = getApiClient()
|
|
171
|
+
const queryClient = useQueryClient()
|
|
172
|
+
const mediaKey = useResourceKey('products', productId, 'media')
|
|
173
|
+
|
|
174
|
+
return useCallback(
|
|
175
|
+
async (mediaIds: string[] | undefined) => {
|
|
176
|
+
// Independent deletes, so all at once: a bulk removal of twenty files
|
|
177
|
+
// waits on one round trip rather than twenty.
|
|
178
|
+
const results = await Promise.allSettled(
|
|
179
|
+
(mediaIds ?? []).map((mediaId) => client.deleteProductMedia?.(productId, mediaId)),
|
|
180
|
+
)
|
|
181
|
+
const failed = results.filter((result) => result.status === 'rejected').length
|
|
182
|
+
if (mediaIds?.length) await queryClient.invalidateQueries({ queryKey: mediaKey })
|
|
183
|
+
return failed
|
|
184
|
+
},
|
|
185
|
+
[client, productId, queryClient, mediaKey],
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Removing a file already on the product, straight away.
|
|
191
|
+
*
|
|
192
|
+
* @deprecated The product form now removes files on save — mark them in
|
|
193
|
+
* `removed_media_ids` and commit with `useCommitRemovedProductMedia`. Kept
|
|
194
|
+
* until the next minor release.
|
|
168
195
|
*/
|
|
169
196
|
export function useFormDeleteProductMedia(productId: string) {
|
|
170
197
|
const client = getApiClient()
|
|
@@ -315,7 +315,7 @@ export function VariantEditSheet({ form, variantIndex, formKey, open, onOpenChan
|
|
|
315
315
|
|
|
316
316
|
{cartonTypesSupported && (
|
|
317
317
|
<FormSection title={t('admin.products.variants.sheet.packing')}>
|
|
318
|
-
<p className="text-
|
|
318
|
+
<p className="text-base text-muted-foreground">
|
|
319
319
|
{t('admin.products.variants.sheet.packing_help')}
|
|
320
320
|
</p>
|
|
321
321
|
{knownEmpty && (
|
|
@@ -447,7 +447,7 @@ export function VariantEditSheet({ form, variantIndex, formKey, open, onOpenChan
|
|
|
447
447
|
)}
|
|
448
448
|
|
|
449
449
|
<FormSection title={t('admin.products.variants.sheet.customs')}>
|
|
450
|
-
<p className="text-
|
|
450
|
+
<p className="text-base text-muted-foreground">
|
|
451
451
|
{t('admin.products.variants.sheet.customs_help')}
|
|
452
452
|
</p>
|
|
453
453
|
<div className="grid grid-cols-2 gap-3">
|
|
@@ -503,7 +503,7 @@ export function VariantEditSheet({ form, variantIndex, formKey, open, onOpenChan
|
|
|
503
503
|
</FormSection>
|
|
504
504
|
|
|
505
505
|
<FormSection title={t('admin.products.variants.sheet.ordering')}>
|
|
506
|
-
<p className="text-
|
|
506
|
+
<p className="text-base text-muted-foreground">
|
|
507
507
|
{t('admin.products.variants.sheet.ordering_help')}
|
|
508
508
|
</p>
|
|
509
509
|
<div className="grid grid-cols-2 gap-3">
|
|
@@ -110,7 +110,12 @@ export function VariantsOptionsBuilder({ selected, onChange, extraOptionTypes }:
|
|
|
110
110
|
const isEditing = expandedIndex === i
|
|
111
111
|
if (isEditing && optionType) {
|
|
112
112
|
return (
|
|
113
|
-
<
|
|
113
|
+
<Item
|
|
114
|
+
variant="outline"
|
|
115
|
+
render={<li />}
|
|
116
|
+
key={ot.id}
|
|
117
|
+
className="flex-col items-stretch border-border-subtle"
|
|
118
|
+
>
|
|
114
119
|
<OptionPicker
|
|
115
120
|
optionType={optionType}
|
|
116
121
|
initialValues={ot.values}
|
|
@@ -130,7 +135,7 @@ export function VariantsOptionsBuilder({ selected, onChange, extraOptionTypes }:
|
|
|
130
135
|
})
|
|
131
136
|
}
|
|
132
137
|
/>
|
|
133
|
-
</
|
|
138
|
+
</Item>
|
|
134
139
|
)
|
|
135
140
|
}
|
|
136
141
|
return (
|
|
@@ -157,7 +162,7 @@ export function VariantsOptionsBuilder({ selected, onChange, extraOptionTypes }:
|
|
|
157
162
|
</Button>
|
|
158
163
|
<Button
|
|
159
164
|
type="button"
|
|
160
|
-
variant="ghost"
|
|
165
|
+
variant="destructive-ghost"
|
|
161
166
|
size="icon-sm"
|
|
162
167
|
aria-label={t('admin.actions.remove')}
|
|
163
168
|
onClick={() => removeAt(i)}
|
|
@@ -172,14 +177,14 @@ export function VariantsOptionsBuilder({ selected, onChange, extraOptionTypes }:
|
|
|
172
177
|
)}
|
|
173
178
|
|
|
174
179
|
{isAdding ? (
|
|
175
|
-
<
|
|
180
|
+
<Item variant="outline" className="flex-col items-stretch border-border-subtle">
|
|
176
181
|
<AddOptionForm
|
|
177
182
|
availableTypes={availableTypes}
|
|
178
183
|
allOptionTypes={allOptionTypes}
|
|
179
184
|
onCancel={() => setIsAdding(false)}
|
|
180
185
|
onSave={(payload) => upsertAt(null, payload)}
|
|
181
186
|
/>
|
|
182
|
-
</
|
|
187
|
+
</Item>
|
|
183
188
|
) : (
|
|
184
189
|
<div>
|
|
185
190
|
<Button type="button" variant="outline" size="sm" onClick={() => setIsAdding(true)}>
|
|
@@ -373,7 +378,7 @@ function OptionPicker({ optionType, initialValues, onSave, onCancel }: OptionPic
|
|
|
373
378
|
return (
|
|
374
379
|
<div className="flex flex-col gap-3">
|
|
375
380
|
<div className="flex items-center justify-between">
|
|
376
|
-
<div className="text-
|
|
381
|
+
<div className="text-base font-medium">{optionType.label}</div>
|
|
377
382
|
</div>
|
|
378
383
|
<div className="flex flex-wrap gap-2">
|
|
379
384
|
{available.map((v) => {
|
|
@@ -461,7 +466,7 @@ function CreateOptionTypeInline({
|
|
|
461
466
|
|
|
462
467
|
return (
|
|
463
468
|
<div className="flex flex-col gap-3">
|
|
464
|
-
<div className="text-
|
|
469
|
+
<div className="text-base font-medium">
|
|
465
470
|
{t('admin.products.variants.create_option_type_title')}
|
|
466
471
|
</div>
|
|
467
472
|
<Field>
|
|
@@ -530,7 +535,7 @@ function CreateOptionValueInline({ onCancel, onCreate, isPending }: CreateOption
|
|
|
530
535
|
|
|
531
536
|
return (
|
|
532
537
|
<div className="flex flex-col gap-3">
|
|
533
|
-
<div className="text-
|
|
538
|
+
<div className="text-base font-medium">{t('admin.products.variants.create_value_title')}</div>
|
|
534
539
|
<Field>
|
|
535
540
|
<FieldLabel htmlFor="new-option-value-label">
|
|
536
541
|
{t('admin.fields.option_value.label.label')}
|
|
@@ -33,12 +33,23 @@ import {
|
|
|
33
33
|
TableHead,
|
|
34
34
|
TableHeader,
|
|
35
35
|
TableRow,
|
|
36
|
+
Tooltip,
|
|
37
|
+
TooltipContent,
|
|
38
|
+
TooltipTrigger,
|
|
36
39
|
} from '@spree/dashboard-ui'
|
|
37
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
ImageIcon,
|
|
42
|
+
ImagePlusIcon,
|
|
43
|
+
PencilIcon,
|
|
44
|
+
SwatchBookIcon,
|
|
45
|
+
TriangleAlertIcon,
|
|
46
|
+
XIcon,
|
|
47
|
+
} from '@spree/dashboard-ui/icons'
|
|
38
48
|
import { type CSSProperties, useEffect, useMemo, useState } from 'react'
|
|
39
49
|
import { type UseFormReturn, useFieldArray, useWatch } from 'react-hook-form'
|
|
40
50
|
import { useTranslation } from 'react-i18next'
|
|
41
51
|
import type { PanelOptionType as OptionType } from '../api-client'
|
|
52
|
+
import { MediaPickerDialog } from '../components/media-picker-dialog'
|
|
42
53
|
import { BulkVariantsDialog } from './bulk-variants-dialog'
|
|
43
54
|
import {
|
|
44
55
|
useFormOptionTypes as useOptionTypes,
|
|
@@ -145,6 +156,7 @@ export function VariantsSection({ form, seedFromType = false, formKey = 'product
|
|
|
145
156
|
})
|
|
146
157
|
|
|
147
158
|
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
|
159
|
+
const [pickingImagesFor, setPickingImagesFor] = useState<number | null>(null)
|
|
148
160
|
const [sheetOpen, setSheetOpen] = useState(false)
|
|
149
161
|
const [bulkOpen, setBulkOpen] = useState(false)
|
|
150
162
|
const [orphanedKeys, setOrphanedKeys] = useState<string[]>([])
|
|
@@ -407,7 +419,7 @@ export function VariantsSection({ form, seedFromType = false, formKey = 'product
|
|
|
407
419
|
items={fields.map((f) => f._key)}
|
|
408
420
|
strategy={verticalListSortingStrategy}
|
|
409
421
|
>
|
|
410
|
-
<Table>
|
|
422
|
+
<Table roundedTop>
|
|
411
423
|
<TableHeader>
|
|
412
424
|
<TableRow>
|
|
413
425
|
{!isSimpleProduct && (
|
|
@@ -433,6 +445,7 @@ export function VariantsSection({ form, seedFromType = false, formKey = 'product
|
|
|
433
445
|
optionTypes={allOptionTypes}
|
|
434
446
|
onEdit={() => handleEditRow(index)}
|
|
435
447
|
onRemove={() => handleRowRemove(index)}
|
|
448
|
+
onPickImages={() => setPickingImagesFor(index)}
|
|
436
449
|
/>
|
|
437
450
|
))}
|
|
438
451
|
</TableBody>
|
|
@@ -445,6 +458,15 @@ export function VariantsSection({ form, seedFromType = false, formKey = 'product
|
|
|
445
458
|
|
|
446
459
|
<BulkVariantsDialog form={form} open={bulkOpen} onOpenChange={setBulkOpen} />
|
|
447
460
|
|
|
461
|
+
{pickingImagesFor !== null && (
|
|
462
|
+
<VariantImagesPicker
|
|
463
|
+
form={form}
|
|
464
|
+
variantIndex={pickingImagesFor}
|
|
465
|
+
optionTypes={allOptionTypes}
|
|
466
|
+
onClose={() => setPickingImagesFor(null)}
|
|
467
|
+
/>
|
|
468
|
+
)}
|
|
469
|
+
|
|
448
470
|
{editingIndex !== null && (
|
|
449
471
|
<VariantEditSheet
|
|
450
472
|
form={form}
|
|
@@ -470,6 +492,7 @@ interface SortableVariantRowProps {
|
|
|
470
492
|
optionTypes: OptionType[]
|
|
471
493
|
onEdit: () => void
|
|
472
494
|
onRemove: () => void
|
|
495
|
+
onPickImages: () => void
|
|
473
496
|
}
|
|
474
497
|
|
|
475
498
|
function SortableVariantRow({
|
|
@@ -480,6 +503,7 @@ function SortableVariantRow({
|
|
|
480
503
|
optionTypes,
|
|
481
504
|
onEdit,
|
|
482
505
|
onRemove,
|
|
506
|
+
onPickImages,
|
|
483
507
|
}: SortableVariantRowProps) {
|
|
484
508
|
const { t } = useTranslation()
|
|
485
509
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
@@ -510,7 +534,19 @@ function SortableVariantRow({
|
|
|
510
534
|
<DragHandle attributes={attributes} listeners={listeners} />
|
|
511
535
|
</TableCell>
|
|
512
536
|
)}
|
|
513
|
-
<TableCell className="font-medium">
|
|
537
|
+
<TableCell className="font-medium">
|
|
538
|
+
<div className="flex items-center gap-3">
|
|
539
|
+
{!isSimpleProduct && (
|
|
540
|
+
<VariantImageButton
|
|
541
|
+
form={form}
|
|
542
|
+
variantId={variant.id}
|
|
543
|
+
label={label}
|
|
544
|
+
onPick={onPickImages}
|
|
545
|
+
/>
|
|
546
|
+
)}
|
|
547
|
+
{label}
|
|
548
|
+
</div>
|
|
549
|
+
</TableCell>
|
|
514
550
|
<TableCell>
|
|
515
551
|
<Input
|
|
516
552
|
aria-label={t('admin.fields.variant.sku.label')}
|
|
@@ -532,7 +568,7 @@ function SortableVariantRow({
|
|
|
532
568
|
{!isSimpleProduct && (
|
|
533
569
|
<Button
|
|
534
570
|
type="button"
|
|
535
|
-
variant="ghost"
|
|
571
|
+
variant="destructive-ghost"
|
|
536
572
|
size="icon-sm"
|
|
537
573
|
aria-label={t('admin.products.variants.remove_row_aria', { name: label })}
|
|
538
574
|
onClick={onRemove}
|
|
@@ -545,3 +581,159 @@ function SortableVariantRow({
|
|
|
545
581
|
</TableRow>
|
|
546
582
|
)
|
|
547
583
|
}
|
|
584
|
+
|
|
585
|
+
type MediaRow = NonNullable<ProductFormValues['media']>[number]
|
|
586
|
+
|
|
587
|
+
/** A media row's stable key: its id once saved, before that its upload's. */
|
|
588
|
+
function mediaRowKey(media: MediaRow): string | null {
|
|
589
|
+
return media.id ?? media.signed_id ?? media.uploadId ?? null
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* A variant's image: its first assigned image (in gallery
|
|
594
|
+
* order) as a thumbnail, with a count when it has more, or an empty dashed
|
|
595
|
+
* square. Opens the picker of the product's images. An unsaved variant has no
|
|
596
|
+
* id to assign media to yet, so its square waits for the save.
|
|
597
|
+
*/
|
|
598
|
+
function VariantImageButton({
|
|
599
|
+
form,
|
|
600
|
+
variantId,
|
|
601
|
+
label,
|
|
602
|
+
onPick,
|
|
603
|
+
}: {
|
|
604
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
605
|
+
form: UseFormReturn<ProductFormValues, any, any>
|
|
606
|
+
variantId?: string | null
|
|
607
|
+
label: string
|
|
608
|
+
onPick: () => void
|
|
609
|
+
}) {
|
|
610
|
+
const { t } = useTranslation()
|
|
611
|
+
const media = useWatch({ control: form.control, name: 'media' }) ?? []
|
|
612
|
+
const assigned = variantId ? media.filter((m) => m.variant_ids?.includes(variantId)) : []
|
|
613
|
+
const first = assigned[0]
|
|
614
|
+
const preview = first?.previewUrl ?? null
|
|
615
|
+
|
|
616
|
+
const button = (
|
|
617
|
+
<button
|
|
618
|
+
type="button"
|
|
619
|
+
// `aria-disabled` rather than `disabled`: a disabled button gets no
|
|
620
|
+
// pointer events, so the tooltip saying why could never open.
|
|
621
|
+
onClick={variantId ? onPick : undefined}
|
|
622
|
+
aria-disabled={!variantId || undefined}
|
|
623
|
+
aria-label={t('admin.products.variants.images_aria', { name: label })}
|
|
624
|
+
className={cn(
|
|
625
|
+
'relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md bg-card text-muted-foreground transition-colors hover:text-foreground focus-visible:shadow-field-focus aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:hover:text-muted-foreground',
|
|
626
|
+
first ? 'border border-border-card' : 'border border-border border-dashed',
|
|
627
|
+
)}
|
|
628
|
+
>
|
|
629
|
+
{preview ? (
|
|
630
|
+
<img src={preview} alt="" className="size-full object-cover" />
|
|
631
|
+
) : first ? (
|
|
632
|
+
<ImageIcon className="size-4" />
|
|
633
|
+
) : (
|
|
634
|
+
<ImagePlusIcon className="size-4" />
|
|
635
|
+
)}
|
|
636
|
+
{assigned.length > 1 && (
|
|
637
|
+
<span className="absolute right-0.5 bottom-0.5 rounded bg-black/60 px-1 text-[10px] text-white tabular-nums leading-4">
|
|
638
|
+
+{assigned.length - 1}
|
|
639
|
+
</span>
|
|
640
|
+
)}
|
|
641
|
+
</button>
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
if (variantId) return button
|
|
645
|
+
|
|
646
|
+
return (
|
|
647
|
+
<Tooltip>
|
|
648
|
+
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
|
649
|
+
<TooltipContent>{t('admin.products.variants.images_unsaved')}</TooltipContent>
|
|
650
|
+
</Tooltip>
|
|
651
|
+
)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* The media picker over this product's own images, with the variant's
|
|
656
|
+
* current ones ticked. Done applies exactly the ticked set — the same
|
|
657
|
+
* many-to-many assignment the media editor's variant pills make, seen from
|
|
658
|
+
* the variant's side — and, like every product edit, saves with the product.
|
|
659
|
+
*/
|
|
660
|
+
function VariantImagesPicker({
|
|
661
|
+
form,
|
|
662
|
+
variantIndex,
|
|
663
|
+
optionTypes,
|
|
664
|
+
onClose,
|
|
665
|
+
}: {
|
|
666
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
667
|
+
form: UseFormReturn<ProductFormValues, any, any>
|
|
668
|
+
variantIndex: number
|
|
669
|
+
optionTypes: OptionType[]
|
|
670
|
+
onClose: () => void
|
|
671
|
+
}) {
|
|
672
|
+
const { t } = useTranslation()
|
|
673
|
+
const variant = form.getValues(`variants.${variantIndex}`)
|
|
674
|
+
const variantId = variant?.id
|
|
675
|
+
if (!variant || !variantId) return null
|
|
676
|
+
|
|
677
|
+
const label = variantDisplayLabel(
|
|
678
|
+
variant,
|
|
679
|
+
t('admin.products.variants.default_variant'),
|
|
680
|
+
optionTypes,
|
|
681
|
+
)
|
|
682
|
+
const rows = form.getValues('media') ?? []
|
|
683
|
+
const options = rows.flatMap((m) => {
|
|
684
|
+
const key = mediaRowKey(m)
|
|
685
|
+
if (!key) return []
|
|
686
|
+
return [
|
|
687
|
+
{
|
|
688
|
+
id: key,
|
|
689
|
+
alt: m.alt,
|
|
690
|
+
filename: m.filename,
|
|
691
|
+
media_type: m.media_type,
|
|
692
|
+
small_url: m.previewUrl ?? null,
|
|
693
|
+
},
|
|
694
|
+
]
|
|
695
|
+
})
|
|
696
|
+
const assigned = new Set(
|
|
697
|
+
rows.filter((m) => m.variant_ids?.includes(variantId)).flatMap((m) => mediaRowKey(m) ?? []),
|
|
698
|
+
)
|
|
699
|
+
const initialSelection = options.filter((option) => assigned.has(option.id))
|
|
700
|
+
|
|
701
|
+
return (
|
|
702
|
+
<MediaPickerDialog
|
|
703
|
+
open
|
|
704
|
+
onOpenChange={(open) => !open && onClose()}
|
|
705
|
+
// Keyed on the files and their alt text, so a file added or renamed
|
|
706
|
+
// since the last opening shows up rather than a cached copy.
|
|
707
|
+
queryKey={`variant-images-${variantId}-${options.map((o) => `${o.id}:${o.alt ?? ''}`).join(',')}`}
|
|
708
|
+
// Local, so the list is the form's — including files added this session.
|
|
709
|
+
search={async (query) => ({
|
|
710
|
+
data: query
|
|
711
|
+
? options.filter((o) =>
|
|
712
|
+
`${o.alt ?? ''} ${o.filename ?? ''}`.toLowerCase().includes(query.toLowerCase()),
|
|
713
|
+
)
|
|
714
|
+
: options,
|
|
715
|
+
})}
|
|
716
|
+
initialSelection={initialSelection}
|
|
717
|
+
allowEmptyConfirm
|
|
718
|
+
browseControls={false}
|
|
719
|
+
// Listed in the Media card's order, so the first ticked is the one the
|
|
720
|
+
// variant row shows; the badge says which, live.
|
|
721
|
+
firstSelectedBadge={t('admin.products.variants.thumbnail_badge')}
|
|
722
|
+
title={t('admin.products.variants.images_title', { name: label })}
|
|
723
|
+
description={t('admin.products.variants.images_description')}
|
|
724
|
+
confirmLabel={t('admin.actions.done')}
|
|
725
|
+
onConfirm={(picked) => {
|
|
726
|
+
const ticked = new Set(picked.map((media) => media.id))
|
|
727
|
+
const next = (form.getValues('media') ?? []).map((m) => {
|
|
728
|
+
const ids = new Set(m.variant_ids ?? [])
|
|
729
|
+
const key = mediaRowKey(m)
|
|
730
|
+
if (key && ticked.has(key)) ids.add(variantId)
|
|
731
|
+
else ids.delete(variantId)
|
|
732
|
+
return { ...m, variant_ids: [...ids] }
|
|
733
|
+
})
|
|
734
|
+
form.setValue('media', next, { shouldDirty: true })
|
|
735
|
+
onClose()
|
|
736
|
+
}}
|
|
737
|
+
/>
|
|
738
|
+
)
|
|
739
|
+
}
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
} from '@spree/admin-sdk'
|
|
8
8
|
import { createContext, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
|
9
9
|
import { getApiClient, type PanelSession } from '../api-client'
|
|
10
|
+
import { clearDeveloperInspector } from '../lib/developer-inspector'
|
|
10
11
|
import { ADMIN_LOCALE_STORAGE_KEY, sessionLocale, switchLocale } from '../lib/i18n'
|
|
11
12
|
import { queryClient } from '../lib/query-client'
|
|
12
13
|
|
|
@@ -122,6 +123,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|
|
122
123
|
// landed. Server-side authorization is unaffected either way; this is about
|
|
123
124
|
// not showing one principal what another may do.
|
|
124
125
|
queryClient.clear()
|
|
126
|
+
clearDeveloperInspector()
|
|
125
127
|
setToken(null)
|
|
126
128
|
setUser(null)
|
|
127
129
|
clearRefreshTimer()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { toastManager } from '@spree/dashboard-ui'
|
|
2
|
+
import { useHotkey } from '@tanstack/react-hotkeys'
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
lazy,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
Suspense,
|
|
8
|
+
useContext,
|
|
9
|
+
useEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useState,
|
|
12
|
+
useSyncExternalStore,
|
|
13
|
+
} from 'react'
|
|
14
|
+
import { useTranslation } from 'react-i18next'
|
|
15
|
+
import {
|
|
16
|
+
getDeveloperInspectorEntries,
|
|
17
|
+
subscribeDeveloperInspector,
|
|
18
|
+
} from '../lib/developer-inspector'
|
|
19
|
+
|
|
20
|
+
// Developer-only, so the drawer and its JSON viewer ship in their own chunk,
|
|
21
|
+
// fetched the first time an admin opens it.
|
|
22
|
+
const DeveloperInspectorDrawer = lazy(() =>
|
|
23
|
+
import('../components/developer-inspector-drawer').then((m) => ({
|
|
24
|
+
default: m.DeveloperInspectorDrawer,
|
|
25
|
+
})),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
export const DEVELOPER_INSPECTOR_HOTKEY = 'Mod+Shift+D'
|
|
29
|
+
|
|
30
|
+
interface DeveloperInspectorState {
|
|
31
|
+
open: boolean
|
|
32
|
+
setOpen: (open: boolean) => void
|
|
33
|
+
/** Opens the panel's feedback dialog, when it has one; `'bug'` opens it on a bug report. */
|
|
34
|
+
sendFeedback?: (kind?: 'bug') => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DeveloperInspectorContext = createContext<DeveloperInspectorState | null>(null)
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The developer inspector: the admin client's recent requests in a bottom
|
|
41
|
+
* drawer, for debugging an integration or filing an issue. Opened from the
|
|
42
|
+
* account menu or the keyboard shortcut.
|
|
43
|
+
*
|
|
44
|
+
* Records only what `adminClient` sends, so a panel built on another client
|
|
45
|
+
* mounts it to no effect.
|
|
46
|
+
*/
|
|
47
|
+
export function DeveloperInspectorProvider({
|
|
48
|
+
children,
|
|
49
|
+
systemInfoPath,
|
|
50
|
+
onSendFeedback,
|
|
51
|
+
}: {
|
|
52
|
+
children: ReactNode
|
|
53
|
+
/**
|
|
54
|
+
* Offered in the account menu, the drawer and on a server error, when the
|
|
55
|
+
* panel takes feedback.
|
|
56
|
+
*/
|
|
57
|
+
onSendFeedback?: (kind?: 'bug') => void
|
|
58
|
+
/** Linked from the drawer header, when the panel has a system info page. */
|
|
59
|
+
systemInfoPath?: string
|
|
60
|
+
}) {
|
|
61
|
+
const [open, setOpen] = useState(false)
|
|
62
|
+
// Latches on first open so the lazy chunk loads only for admins who use it,
|
|
63
|
+
// and the drawer keeps its state between openings.
|
|
64
|
+
const [mounted, setMounted] = useState(false)
|
|
65
|
+
|
|
66
|
+
const value = useMemo<DeveloperInspectorState>(
|
|
67
|
+
() => ({
|
|
68
|
+
open,
|
|
69
|
+
setOpen: (next) => {
|
|
70
|
+
if (next) setMounted(true)
|
|
71
|
+
setOpen(next)
|
|
72
|
+
},
|
|
73
|
+
sendFeedback: onSendFeedback,
|
|
74
|
+
}),
|
|
75
|
+
[open, onSendFeedback],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
useServerErrorToast(onSendFeedback)
|
|
79
|
+
|
|
80
|
+
useHotkey(DEVELOPER_INSPECTOR_HOTKEY, () => value.setOpen(!open))
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<DeveloperInspectorContext.Provider value={value}>
|
|
84
|
+
{children}
|
|
85
|
+
{mounted && (
|
|
86
|
+
<Suspense fallback={null}>
|
|
87
|
+
<DeveloperInspectorDrawer
|
|
88
|
+
open={value.open}
|
|
89
|
+
onOpenChange={value.setOpen}
|
|
90
|
+
systemInfoPath={systemInfoPath}
|
|
91
|
+
/>
|
|
92
|
+
</Suspense>
|
|
93
|
+
)}
|
|
94
|
+
</DeveloperInspectorContext.Provider>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// One toast for a burst of failures: a failed query is retried, and a page
|
|
99
|
+
// often fires several requests that fail for the same reason.
|
|
100
|
+
const SERVER_ERROR_TOAST_INTERVAL_MS = 10_000
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Offers to report a bug when the Admin API answers with a 5xx: that is a
|
|
104
|
+
* bug in Spree or the installation, not something the admin did wrong.
|
|
105
|
+
*/
|
|
106
|
+
function useServerErrorToast(sendFeedback?: (kind?: 'bug') => void) {
|
|
107
|
+
const { t } = useTranslation()
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!sendFeedback) return
|
|
111
|
+
let seen = getDeveloperInspectorEntries()[0]?.id ?? 0
|
|
112
|
+
let toastedAt = 0
|
|
113
|
+
|
|
114
|
+
return subscribeDeveloperInspector(() => {
|
|
115
|
+
const entries = getDeveloperInspectorEntries()
|
|
116
|
+
const fresh = entries.filter((entry) => entry.id > seen)
|
|
117
|
+
seen = entries[0]?.id ?? seen
|
|
118
|
+
const failure = fresh.find((entry) => entry.status !== null && entry.status >= 500)
|
|
119
|
+
if (!failure || Date.now() - toastedAt < SERVER_ERROR_TOAST_INTERVAL_MS) return
|
|
120
|
+
|
|
121
|
+
toastedAt = Date.now()
|
|
122
|
+
toastManager.add({
|
|
123
|
+
type: 'error',
|
|
124
|
+
title: t('admin.components.server_error.title'),
|
|
125
|
+
description: failure.requestId
|
|
126
|
+
? t('admin.components.server_error.request_id', { id: failure.requestId })
|
|
127
|
+
: undefined,
|
|
128
|
+
actionProps: {
|
|
129
|
+
children: t('admin.components.server_error.report'),
|
|
130
|
+
onClick: () => sendFeedback('bug'),
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
}, [sendFeedback, t])
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The inspector's state when a provider is mounted, `null` otherwise. */
|
|
138
|
+
export function useOptionalDeveloperInspector(): DeveloperInspectorState | null {
|
|
139
|
+
return useContext(DeveloperInspectorContext)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The recorded requests, newest first. */
|
|
143
|
+
export function useDeveloperInspectorEntries() {
|
|
144
|
+
return useSyncExternalStore(subscribeDeveloperInspector, getDeveloperInspectorEntries)
|
|
145
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { JsonValueLinkResolver } from '@spree/dashboard-ui/spree/json-preview-drawer'
|
|
2
|
+
import { createContext, type ReactNode, useContext } from 'react'
|
|
3
|
+
|
|
4
|
+
const JsonLinkResolverContext = createContext<JsonValueLinkResolver | undefined>(undefined)
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Supplies the resolver every "View as JSON" drawer uses to turn IDs in a
|
|
8
|
+
* payload into links. The admin dashboard mounts it with its prefixed-ID
|
|
9
|
+
* routes; a panel that mounts none gets plain, unlinked JSON.
|
|
10
|
+
*/
|
|
11
|
+
export function JsonLinkResolverProvider({
|
|
12
|
+
resolveLink,
|
|
13
|
+
children,
|
|
14
|
+
}: {
|
|
15
|
+
resolveLink: JsonValueLinkResolver
|
|
16
|
+
children: ReactNode
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<JsonLinkResolverContext.Provider value={resolveLink}>
|
|
20
|
+
{children}
|
|
21
|
+
</JsonLinkResolverContext.Provider>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useJsonLinkResolver(): JsonValueLinkResolver | undefined {
|
|
26
|
+
return useContext(JsonLinkResolverContext)
|
|
27
|
+
}
|