@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,9 +1,10 @@
|
|
|
1
|
-
import { Card, SearchInput } from '@spree/dashboard-ui'
|
|
2
|
-
import { PackageIcon } from '@spree/dashboard-ui/icons'
|
|
3
|
-
import { Link } from '@tanstack/react-router'
|
|
1
|
+
import { Card, cn, SearchInput } from '@spree/dashboard-ui'
|
|
2
|
+
import { ChevronRightIcon, PackageIcon } from '@spree/dashboard-ui/icons'
|
|
3
|
+
import { Link, Navigate } from '@tanstack/react-router'
|
|
4
4
|
import { useMemo, useState } from 'react'
|
|
5
5
|
import { useTranslation } from 'react-i18next'
|
|
6
6
|
import { PageHeader } from '../components/page-header'
|
|
7
|
+
import { useSettingsRailShown } from '../components/settings-sidebar'
|
|
7
8
|
import { resolveNavDescription, resolveNavLabel } from '../lib/nav-registry'
|
|
8
9
|
import {
|
|
9
10
|
filterSettingsByPermissions,
|
|
@@ -14,21 +15,25 @@ import {
|
|
|
14
15
|
import { usePermissions } from '../providers/permission-provider'
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
* Settings landing page —
|
|
18
|
-
*
|
|
18
|
+
* Settings landing page — every settings area the current member can reach,
|
|
19
|
+
* one card of rows per group, grouped the same way the sidebar groups them.
|
|
20
|
+
*
|
|
21
|
+
* Phones and tablets only. From `lg` the settings rail lists the same entries
|
|
22
|
+
* beside the page, so this page would only repeat it; there it hands over to
|
|
23
|
+
* the first section the member can open instead.
|
|
19
24
|
*
|
|
20
25
|
* Shared by the operator dashboard and the marketplace seller panel. Each
|
|
21
26
|
* host passes its tenant id so the cards link under that panel's prefix
|
|
22
27
|
* (`/$storeId/settings/…` or `/$sellerId/settings/…`).
|
|
23
28
|
*
|
|
24
29
|
* It doubles as the settings navigation below `lg`, where `SettingsSidebar` is
|
|
25
|
-
* hidden: on a narrow viewport this
|
|
26
|
-
* must
|
|
30
|
+
* hidden: on a narrow viewport this list is the only way into the area, so the
|
|
31
|
+
* redirect must stay bound to the rail being on screen.
|
|
27
32
|
*/
|
|
28
33
|
export function SettingsIndexPage({ tenantId }: { tenantId: string }) {
|
|
29
34
|
const { t } = useTranslation()
|
|
30
35
|
const snapshot = useSettingsNav()
|
|
31
|
-
const { permissions } = usePermissions()
|
|
36
|
+
const { permissions, isLoading: permissionsLoading } = usePermissions()
|
|
32
37
|
const [query, setQuery] = useState('')
|
|
33
38
|
|
|
34
39
|
// Permission filtering depends only on the snapshot, so it survives typing.
|
|
@@ -38,42 +43,52 @@ export function SettingsIndexPage({ tenantId }: { tenantId: string }) {
|
|
|
38
43
|
)
|
|
39
44
|
const groups = useMemo(() => filterSettingsByQuery(allowed, query, t).groups, [allowed, query, t])
|
|
40
45
|
|
|
46
|
+
const railShown = useSettingsRailShown()
|
|
47
|
+
const first = railShown
|
|
48
|
+
? allowed.groups.flatMap((g) => g.entries).find((entry) => !entry.comingSoon)
|
|
49
|
+
: undefined
|
|
50
|
+
if (first) return <Navigate to={`/${tenantId}/settings${first.path}` as never} replace />
|
|
51
|
+
// Nothing to open yet while permissions load; render nothing rather than
|
|
52
|
+
// flash the list the rail already shows. Loaded with nothing to open, the
|
|
53
|
+
// list below says so rather than leaving the page blank.
|
|
54
|
+
if (railShown && permissionsLoading) return null
|
|
55
|
+
|
|
41
56
|
return (
|
|
42
|
-
<div className="flex flex-col gap-
|
|
57
|
+
<div className="flex flex-col gap-8">
|
|
43
58
|
<PageHeader
|
|
44
59
|
title={t('admin.settings_page.title')}
|
|
45
60
|
subtitle={t('admin.settings_page.description')}
|
|
46
61
|
/>
|
|
47
62
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
aria-label={t('admin.settings_page.search_placeholder')}
|
|
57
|
-
clearLabel={t('admin.common.clear')}
|
|
58
|
-
className="h-11 max-w-md"
|
|
59
|
-
/>
|
|
60
|
-
</div>
|
|
63
|
+
<SearchInput
|
|
64
|
+
value={query}
|
|
65
|
+
onValueChange={setQuery}
|
|
66
|
+
placeholder={t('admin.settings_page.search_placeholder')}
|
|
67
|
+
aria-label={t('admin.settings_page.search_placeholder')}
|
|
68
|
+
clearLabel={t('admin.common.clear')}
|
|
69
|
+
className="h-11 max-w-md"
|
|
70
|
+
/>
|
|
61
71
|
|
|
62
72
|
{groups.length === 0 ? (
|
|
63
|
-
<p className="text-
|
|
73
|
+
<p className="text-base text-muted-foreground">
|
|
64
74
|
{t('admin.settings_page.no_results', { query: query.trim() })}
|
|
65
75
|
</p>
|
|
66
76
|
) : (
|
|
67
77
|
groups.map(({ group, entries }) => (
|
|
68
78
|
<section key={group.key} className="flex flex-col gap-3">
|
|
69
|
-
<h2 className="
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
<h2 className="font-semibold text-base">{resolveNavLabel(group, t)}</h2>
|
|
80
|
+
<Card className="gap-0 overflow-hidden">
|
|
81
|
+
<ul>
|
|
82
|
+
{entries.map((entry, index) => (
|
|
83
|
+
<SettingsIndexRow
|
|
84
|
+
key={entry.key}
|
|
85
|
+
entry={entry}
|
|
86
|
+
tenantId={tenantId}
|
|
87
|
+
divided={index > 0}
|
|
88
|
+
/>
|
|
89
|
+
))}
|
|
90
|
+
</ul>
|
|
91
|
+
</Card>
|
|
77
92
|
</section>
|
|
78
93
|
))
|
|
79
94
|
)}
|
|
@@ -81,35 +96,51 @@ export function SettingsIndexPage({ tenantId }: { tenantId: string }) {
|
|
|
81
96
|
)
|
|
82
97
|
}
|
|
83
98
|
|
|
84
|
-
function
|
|
99
|
+
function SettingsIndexRow({
|
|
100
|
+
entry,
|
|
101
|
+
tenantId,
|
|
102
|
+
divided,
|
|
103
|
+
}: {
|
|
104
|
+
entry: SettingsNavEntry
|
|
105
|
+
tenantId: string
|
|
106
|
+
/** Draws the rule above the row's text, which starts past the icon. */
|
|
107
|
+
divided: boolean
|
|
108
|
+
}) {
|
|
85
109
|
const { t } = useTranslation()
|
|
86
110
|
const Icon = entry.icon ?? PackageIcon
|
|
87
111
|
const label = resolveNavLabel(entry, t)
|
|
88
112
|
const description = resolveNavDescription(entry, t)
|
|
89
113
|
|
|
90
114
|
return (
|
|
91
|
-
<
|
|
92
|
-
{/* The whole
|
|
93
|
-
|
|
94
|
-
{/* Paths come from a runtime registry plugins extend, so they can't be
|
|
115
|
+
<li>
|
|
116
|
+
{/* The whole row is the target, so it clears the 44px touch minimum.
|
|
117
|
+
Paths come from a runtime registry plugins extend, so they can't be
|
|
95
118
|
checked against the generated route tree. */}
|
|
96
119
|
<Link
|
|
97
120
|
to={`/${tenantId}/settings${entry.path}` as never}
|
|
98
|
-
// The
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
// use: this link draws no border of its own to recolour.
|
|
102
|
-
className="flex h-full items-start gap-3 rounded-xl p-4 outline-none focus-visible:shadow-[0_0_0_2px_var(--ring)]"
|
|
121
|
+
// The row draws no border of its own, so focus gets an inset ring in
|
|
122
|
+
// place of the suppressed outline.
|
|
123
|
+
className="group flex items-center gap-3.5 ps-4 outline-none transition-colors hover:bg-accent focus-visible:shadow-[inset_0_0_0_2px_var(--ring)]"
|
|
103
124
|
>
|
|
104
|
-
<Icon className="
|
|
105
|
-
<span
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
125
|
+
<Icon className="size-5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
|
|
126
|
+
<span
|
|
127
|
+
className={cn(
|
|
128
|
+
'flex min-w-0 flex-1 items-center gap-3 py-3.5 pe-4',
|
|
129
|
+
divided && 'border-border-subtle border-t',
|
|
130
|
+
)}
|
|
131
|
+
>
|
|
132
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
133
|
+
<span className="flex items-center gap-1.5 text-base font-medium">
|
|
134
|
+
{label}
|
|
135
|
+
{entry.badge && <entry.badge />}
|
|
136
|
+
</span>
|
|
137
|
+
{description && (
|
|
138
|
+
<span className="text-base text-muted-foreground md:truncate">{description}</span>
|
|
139
|
+
)}
|
|
109
140
|
</span>
|
|
110
|
-
|
|
141
|
+
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground rtl:rotate-180" />
|
|
111
142
|
</span>
|
|
112
143
|
</Link>
|
|
113
|
-
</
|
|
144
|
+
</li>
|
|
114
145
|
)
|
|
115
146
|
}
|
|
@@ -34,7 +34,7 @@ import { Can } from '../components/can'
|
|
|
34
34
|
import { CountryCombobox } from '../components/country-combobox'
|
|
35
35
|
import { StateCombobox, useCountryStates } from '../components/country-state-fields'
|
|
36
36
|
import { RecordMetadataSection } from '../components/record-metadata'
|
|
37
|
-
import { ResourceTable, resourceSearchSchema } from '../components/resource-table'
|
|
37
|
+
import { ResourceTable, resourceSearchSchema, type TableView } from '../components/resource-table'
|
|
38
38
|
import { HostFormSlot } from '../components/slot'
|
|
39
39
|
import {
|
|
40
40
|
canDeleteStockLocations,
|
|
@@ -94,8 +94,11 @@ export function StockLocationsPage({
|
|
|
94
94
|
stockLevelsPanel: StockLevelsPanel,
|
|
95
95
|
activityPanel: ActivityPanel,
|
|
96
96
|
formKey = 'stock_location',
|
|
97
|
+
views,
|
|
97
98
|
}: {
|
|
98
99
|
search: StockLocationsSearch
|
|
100
|
+
/** Tabs splitting the list — the operator's own locations and their sellers'. */
|
|
101
|
+
views?: TableView[]
|
|
99
102
|
/** Names the sheets' slot and extension fields — `seller.stock_location` in the seller panel. */
|
|
100
103
|
formKey?: string
|
|
101
104
|
/** Rendered inside the edit sheet, below the form. Optional — see above. */
|
|
@@ -149,6 +152,7 @@ export function StockLocationsPage({
|
|
|
149
152
|
queryKey="stock-locations"
|
|
150
153
|
queryFn={(params) => listStockLocations(params)}
|
|
151
154
|
searchParams={search}
|
|
155
|
+
views={views}
|
|
152
156
|
rowActions={(location) => (
|
|
153
157
|
<RowActions
|
|
154
158
|
actions={[
|
|
@@ -273,16 +277,20 @@ function CreateStockLocationSheet({
|
|
|
273
277
|
// Edit Sheet
|
|
274
278
|
// ============================================================================
|
|
275
279
|
|
|
276
|
-
|
|
280
|
+
/**
|
|
281
|
+
* One location's editor. Exported so a record page that lists locations (a
|
|
282
|
+
* seller's) opens it in place rather than sending the operator to settings.
|
|
283
|
+
*/
|
|
284
|
+
export function EditStockLocationSheet({
|
|
277
285
|
id,
|
|
278
|
-
formKey,
|
|
286
|
+
formKey = 'stock_location',
|
|
279
287
|
open,
|
|
280
288
|
onOpenChange,
|
|
281
289
|
stockLevelsPanel: StockLevelsPanel,
|
|
282
290
|
activityPanel: ActivityPanel,
|
|
283
291
|
}: {
|
|
284
292
|
id: string
|
|
285
|
-
formKey
|
|
293
|
+
formKey?: string
|
|
286
294
|
open: boolean
|
|
287
295
|
onOpenChange: (open: boolean) => void
|
|
288
296
|
stockLevelsPanel?: ComponentType<{ stockLocationId: string }>
|
|
@@ -330,7 +338,7 @@ function EditStockLocationSheet({
|
|
|
330
338
|
<SheetDescription>{t('admin.stock_locations.edit_description')}</SheetDescription>
|
|
331
339
|
</SheetHeader>
|
|
332
340
|
{isLoading ? (
|
|
333
|
-
<div className="p-4 text-
|
|
341
|
+
<div className="p-4 text-base text-muted-foreground">{t('admin.common.loading')}</div>
|
|
334
342
|
) : (
|
|
335
343
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col">
|
|
336
344
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
|
|
@@ -384,7 +392,7 @@ function StockLocationFormFields({ form }: { form: UseFormReturn<StockLocationFo
|
|
|
384
392
|
// to as well as its own.
|
|
385
393
|
<div className="flex flex-col gap-6">
|
|
386
394
|
{errors.root?.message && (
|
|
387
|
-
<p className="text-
|
|
395
|
+
<p className="text-base text-destructive" role="alert">
|
|
388
396
|
{errors.root.message}
|
|
389
397
|
</p>
|
|
390
398
|
)}
|
|
@@ -175,7 +175,7 @@ export function DigitalAssetEditSheet({
|
|
|
175
175
|
|
|
176
176
|
<p className="text-muted-foreground text-xs">{t('admin.digital_assets.limits_help')}</p>
|
|
177
177
|
|
|
178
|
-
{error && <p className="text-destructive text-
|
|
178
|
+
{error && <p className="text-destructive text-base">{error}</p>}
|
|
179
179
|
</div>
|
|
180
180
|
|
|
181
181
|
<SheetFooter>
|
|
@@ -71,7 +71,7 @@ export function DigitalAssetProviderSheet({ productId, provider, onOpenChange, o
|
|
|
71
71
|
onChange={setValues}
|
|
72
72
|
/>
|
|
73
73
|
|
|
74
|
-
{error && <p className="text-destructive text-
|
|
74
|
+
{error && <p className="text-destructive text-base">{error}</p>}
|
|
75
75
|
</div>
|
|
76
76
|
|
|
77
77
|
<SheetFooter>
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
useConfirm,
|
|
32
32
|
} from '@spree/dashboard-ui'
|
|
33
33
|
import {
|
|
34
|
-
|
|
34
|
+
ChevronsUpDownIcon,
|
|
35
35
|
DownloadIcon,
|
|
36
36
|
FileDownIcon,
|
|
37
37
|
FileIcon,
|
|
@@ -274,7 +274,7 @@ export function DigitalAssetsCard({
|
|
|
274
274
|
{uploading
|
|
275
275
|
? t('admin.digital_assets.uploading_short')
|
|
276
276
|
: t('admin.digital_assets.add')}
|
|
277
|
-
<
|
|
277
|
+
<ChevronsUpDownIcon className="ml-2 size-4" />
|
|
278
278
|
</Button>
|
|
279
279
|
</DropdownMenuTrigger>
|
|
280
280
|
<DropdownMenuContent align="end">
|
|
@@ -298,8 +298,8 @@ export function DigitalAssetsCard({
|
|
|
298
298
|
</CardHeader>
|
|
299
299
|
|
|
300
300
|
<CardContent className="flex flex-col gap-4">
|
|
301
|
-
<p className="text-muted-foreground text-
|
|
302
|
-
{notice && canWrite && <p className="text-
|
|
301
|
+
<p className="text-muted-foreground text-base">{t('admin.digital_assets.description')}</p>
|
|
302
|
+
{notice && canWrite && <p className="text-base">{notice}</p>}
|
|
303
303
|
|
|
304
304
|
<input
|
|
305
305
|
ref={fileInputRef}
|
|
@@ -320,7 +320,7 @@ export function DigitalAssetsCard({
|
|
|
320
320
|
{isLoading ? (
|
|
321
321
|
<Skeleton className="h-24 w-full" />
|
|
322
322
|
) : !hasAssets && !canWrite ? (
|
|
323
|
-
<p className="text-muted-foreground text-
|
|
323
|
+
<p className="text-muted-foreground text-base">{t('admin.digital_assets.empty')}</p>
|
|
324
324
|
) : !hasAssets ? (
|
|
325
325
|
<button
|
|
326
326
|
type="button"
|
|
@@ -331,7 +331,7 @@ export function DigitalAssetsCard({
|
|
|
331
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
332
|
>
|
|
333
333
|
<FileIcon className="size-8 text-muted-foreground" />
|
|
334
|
-
<p className="text-muted-foreground text-
|
|
334
|
+
<p className="text-muted-foreground text-base">
|
|
335
335
|
{uploading
|
|
336
336
|
? t('admin.digital_assets.uploading_short')
|
|
337
337
|
: t('admin.digital_assets.drop_hint')}
|
|
@@ -339,7 +339,7 @@ export function DigitalAssetsCard({
|
|
|
339
339
|
</button>
|
|
340
340
|
) : (
|
|
341
341
|
<div className="overflow-hidden rounded-xl border border-border-subtle">
|
|
342
|
-
<Table>
|
|
342
|
+
<Table roundedTop>
|
|
343
343
|
<TableHeader>
|
|
344
344
|
<TableRow>
|
|
345
345
|
<TableHead>{t('admin.digital_assets.columns.file')}</TableHead>
|
|
@@ -227,10 +227,10 @@ export function InventorySection({ form, stockLocationHref }: InventorySectionPr
|
|
|
227
227
|
// Showing "no stock locations" while the query is still in flight is
|
|
228
228
|
// misleading — the locations may exist but haven't loaded yet.
|
|
229
229
|
if (stockLocationsLoading) {
|
|
230
|
-
return <p className="text-
|
|
230
|
+
return <p className="text-base text-muted-foreground">{t('admin.common.loading')}</p>
|
|
231
231
|
}
|
|
232
232
|
return (
|
|
233
|
-
<p className="text-
|
|
233
|
+
<p className="text-base text-muted-foreground">
|
|
234
234
|
{stockLocations.length === 0
|
|
235
235
|
? t('admin.products.inventory.empty_no_locations')
|
|
236
236
|
: t('admin.products.inventory.empty_no_variants')}
|
|
@@ -2,24 +2,14 @@ import type { ProductFormValues } from '@spree/dashboard-core'
|
|
|
2
2
|
import {
|
|
3
3
|
extensionFieldNames,
|
|
4
4
|
ImageUploadField,
|
|
5
|
+
MediaEditorDialog,
|
|
6
|
+
MediaEditorSection,
|
|
5
7
|
parseVideoUrl,
|
|
6
8
|
Slot,
|
|
7
9
|
useTranslation,
|
|
8
10
|
} from '@spree/dashboard-core'
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
Field,
|
|
12
|
-
FieldError,
|
|
13
|
-
FieldLabel,
|
|
14
|
-
Input,
|
|
15
|
-
MediaPreview,
|
|
16
|
-
Sheet,
|
|
17
|
-
SheetContent,
|
|
18
|
-
SheetFooter,
|
|
19
|
-
SheetHeader,
|
|
20
|
-
SheetTitle,
|
|
21
|
-
} from '@spree/dashboard-ui'
|
|
22
|
-
import { CheckIcon } from '@spree/dashboard-ui/icons'
|
|
11
|
+
import { Button, Field, FieldError, FieldLabel, Input, MediaPreview } from '@spree/dashboard-ui'
|
|
12
|
+
import { CheckIcon, InfoIcon, LayersIcon } from '@spree/dashboard-ui/icons'
|
|
23
13
|
import { useCallback, useEffect, useRef } from 'react'
|
|
24
14
|
import type { UseFormReturn } from 'react-hook-form'
|
|
25
15
|
import { variantLabel } from './assignable-variants'
|
|
@@ -51,7 +41,7 @@ interface Props {
|
|
|
51
41
|
productId?: string
|
|
52
42
|
}
|
|
53
43
|
|
|
54
|
-
// Form-backed media editor.
|
|
44
|
+
// Form-backed media editor, full screen. It edits `form.media[index]` directly —
|
|
55
45
|
// changes ride the parent product PATCH/POST, no per-asset API calls.
|
|
56
46
|
// Cancel restores a snapshot taken on open; Done runs the slot widgets' own
|
|
57
47
|
// saves, then closes (parent form Save commits). Same model as the per-variant
|
|
@@ -250,121 +240,12 @@ export function MediaEditSheet({
|
|
|
250
240
|
}
|
|
251
241
|
|
|
252
242
|
return (
|
|
253
|
-
<
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-5">
|
|
260
|
-
<MediaPreview
|
|
261
|
-
mediaType={entry.media_type}
|
|
262
|
-
previewUrl={previewUrl}
|
|
263
|
-
videoUrl={videoUrl}
|
|
264
|
-
embedUrl={parsedVideo?.embedUrl}
|
|
265
|
-
alt={alt}
|
|
266
|
-
focalPoint={focalPoint}
|
|
267
|
-
onFocalPointClick={handleFocalPointClick}
|
|
268
|
-
/>
|
|
269
|
-
|
|
270
|
-
{isImage && previewUrl && (
|
|
271
|
-
<div className="flex items-center justify-between gap-2 -mt-3">
|
|
272
|
-
<p className="text-xs text-muted-foreground">
|
|
273
|
-
{t('admin.products.media.focal_point_help')}
|
|
274
|
-
</p>
|
|
275
|
-
{focalPoint && (
|
|
276
|
-
<Button type="button" variant="ghost" size="sm" onClick={clearFocalPoint}>
|
|
277
|
-
{t('admin.products.media.focal_point_reset')}
|
|
278
|
-
</Button>
|
|
279
|
-
)}
|
|
280
|
-
</div>
|
|
281
|
-
)}
|
|
282
|
-
|
|
283
|
-
{isExternalVideo && (
|
|
284
|
-
<Field>
|
|
285
|
-
<FieldLabel htmlFor="media-video-url">
|
|
286
|
-
{t('admin.fields.media.external_video_url.label')}
|
|
287
|
-
</FieldLabel>
|
|
288
|
-
<Input
|
|
289
|
-
id="media-video-url"
|
|
290
|
-
value={externalVideoUrl}
|
|
291
|
-
aria-invalid={videoUrlInvalid || undefined}
|
|
292
|
-
onChange={(e) => setExternalVideoUrl(e.target.value)}
|
|
293
|
-
placeholder={t('admin.fields.media.external_video_url.placeholder')}
|
|
294
|
-
/>
|
|
295
|
-
{videoUrlInvalid && (
|
|
296
|
-
<FieldError>{t('admin.products.media.video_url_invalid')}</FieldError>
|
|
297
|
-
)}
|
|
298
|
-
</Field>
|
|
299
|
-
)}
|
|
300
|
-
|
|
301
|
-
<Field>
|
|
302
|
-
<FieldLabel htmlFor="media-alt">{t('admin.fields.media.alt.label')}</FieldLabel>
|
|
303
|
-
<Input
|
|
304
|
-
id="media-alt"
|
|
305
|
-
value={alt}
|
|
306
|
-
onChange={(e) => setAlt(e.target.value)}
|
|
307
|
-
placeholder={t('admin.fields.media.alt.placeholder')}
|
|
308
|
-
/>
|
|
309
|
-
</Field>
|
|
310
|
-
|
|
311
|
-
{isVideo && (
|
|
312
|
-
<Field>
|
|
313
|
-
<FieldLabel>{t('admin.fields.media.poster.label')}</FieldLabel>
|
|
314
|
-
<ImageUploadField
|
|
315
|
-
value={posterValue}
|
|
316
|
-
onChange={setPoster}
|
|
317
|
-
help={t('admin.fields.media.poster.help')}
|
|
318
|
-
/>
|
|
319
|
-
</Field>
|
|
320
|
-
)}
|
|
321
|
-
|
|
322
|
-
{variants.length > 0 && (
|
|
323
|
-
<Field>
|
|
324
|
-
<FieldLabel>{t('admin.products.media.assigned_variants_label')}</FieldLabel>
|
|
325
|
-
<p className="text-xs text-muted-foreground">
|
|
326
|
-
{t('admin.products.media.assigned_variants_help')}
|
|
327
|
-
</p>
|
|
328
|
-
<div className="flex flex-wrap gap-2 pt-2">
|
|
329
|
-
{variants.map((v) => {
|
|
330
|
-
const id = v.id
|
|
331
|
-
if (!id) return null
|
|
332
|
-
const selected = selectedVariantIds.has(id)
|
|
333
|
-
const label = variantLabel(v)
|
|
334
|
-
return (
|
|
335
|
-
<button
|
|
336
|
-
key={id}
|
|
337
|
-
type="button"
|
|
338
|
-
onClick={() => toggleVariant(id)}
|
|
339
|
-
aria-pressed={selected}
|
|
340
|
-
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
|
|
341
|
-
selected
|
|
342
|
-
? 'border-primary bg-primary text-primary-foreground'
|
|
343
|
-
: 'border-border bg-background text-muted-foreground hover:border-muted-foreground hover:text-foreground'
|
|
344
|
-
}`}
|
|
345
|
-
>
|
|
346
|
-
{selected && <CheckIcon className="size-3" />}
|
|
347
|
-
{label}
|
|
348
|
-
</button>
|
|
349
|
-
)
|
|
350
|
-
})}
|
|
351
|
-
</div>
|
|
352
|
-
</Field>
|
|
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
|
-
/>
|
|
365
|
-
</div>
|
|
366
|
-
|
|
367
|
-
<SheetFooter>
|
|
243
|
+
<MediaEditorDialog
|
|
244
|
+
open={open}
|
|
245
|
+
onOpenChange={(o) => (o ? onOpenChange(o) : handleCancel())}
|
|
246
|
+
title={t('admin.products.media.edit_title')}
|
|
247
|
+
actions={
|
|
248
|
+
<>
|
|
368
249
|
<Button type="button" variant="outline" disabled={saving} onClick={handleCancel}>
|
|
369
250
|
{t('admin.actions.cancel')}
|
|
370
251
|
</Button>
|
|
@@ -377,8 +258,130 @@ export function MediaEditSheet({
|
|
|
377
258
|
>
|
|
378
259
|
{t('admin.actions.done')}
|
|
379
260
|
</Button>
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
261
|
+
</>
|
|
262
|
+
}
|
|
263
|
+
preview={
|
|
264
|
+
<MediaPreview
|
|
265
|
+
fit="stage"
|
|
266
|
+
mediaType={entry.media_type}
|
|
267
|
+
previewUrl={previewUrl}
|
|
268
|
+
videoUrl={videoUrl}
|
|
269
|
+
embedUrl={parsedVideo?.embedUrl}
|
|
270
|
+
alt={alt}
|
|
271
|
+
focalPoint={focalPoint}
|
|
272
|
+
onFocalPointClick={handleFocalPointClick}
|
|
273
|
+
/>
|
|
274
|
+
}
|
|
275
|
+
hint={
|
|
276
|
+
isImage && previewUrl ? (
|
|
277
|
+
<>
|
|
278
|
+
<span
|
|
279
|
+
aria-hidden
|
|
280
|
+
className={`size-3 shrink-0 rounded-full border-2 border-white ${focalPoint ? 'bg-blue-500' : ''}`}
|
|
281
|
+
/>
|
|
282
|
+
<span>{t('admin.products.media.focal_point_help')}</span>
|
|
283
|
+
{focalPoint && (
|
|
284
|
+
<Button
|
|
285
|
+
type="button"
|
|
286
|
+
variant="ghost"
|
|
287
|
+
size="xs"
|
|
288
|
+
className="-me-1.5"
|
|
289
|
+
onClick={clearFocalPoint}
|
|
290
|
+
>
|
|
291
|
+
{t('admin.products.media.focal_point_reset')}
|
|
292
|
+
</Button>
|
|
293
|
+
)}
|
|
294
|
+
</>
|
|
295
|
+
) : undefined
|
|
296
|
+
}
|
|
297
|
+
>
|
|
298
|
+
<MediaEditorSection icon={<InfoIcon />} title={t('admin.media_editor.information')}>
|
|
299
|
+
{isExternalVideo && (
|
|
300
|
+
<Field>
|
|
301
|
+
<FieldLabel htmlFor="media-video-url">
|
|
302
|
+
{t('admin.fields.media.external_video_url.label')}
|
|
303
|
+
</FieldLabel>
|
|
304
|
+
<Input
|
|
305
|
+
id="media-video-url"
|
|
306
|
+
value={externalVideoUrl}
|
|
307
|
+
aria-invalid={videoUrlInvalid || undefined}
|
|
308
|
+
onChange={(e) => setExternalVideoUrl(e.target.value)}
|
|
309
|
+
placeholder={t('admin.fields.media.external_video_url.placeholder')}
|
|
310
|
+
/>
|
|
311
|
+
{videoUrlInvalid && (
|
|
312
|
+
<FieldError>{t('admin.products.media.video_url_invalid')}</FieldError>
|
|
313
|
+
)}
|
|
314
|
+
</Field>
|
|
315
|
+
)}
|
|
316
|
+
|
|
317
|
+
<Field>
|
|
318
|
+
<FieldLabel htmlFor="media-alt">{t('admin.fields.media.alt.label')}</FieldLabel>
|
|
319
|
+
<Input
|
|
320
|
+
id="media-alt"
|
|
321
|
+
value={alt}
|
|
322
|
+
onChange={(e) => setAlt(e.target.value)}
|
|
323
|
+
placeholder={t('admin.fields.media.alt.placeholder')}
|
|
324
|
+
/>
|
|
325
|
+
</Field>
|
|
326
|
+
|
|
327
|
+
{isVideo && (
|
|
328
|
+
<Field>
|
|
329
|
+
<FieldLabel>{t('admin.fields.media.poster.label')}</FieldLabel>
|
|
330
|
+
<ImageUploadField
|
|
331
|
+
value={posterValue}
|
|
332
|
+
onChange={setPoster}
|
|
333
|
+
help={t('admin.fields.media.poster.help')}
|
|
334
|
+
/>
|
|
335
|
+
</Field>
|
|
336
|
+
)}
|
|
337
|
+
|
|
338
|
+
{/* Plugin fields join the file's own, as on the media library's editor. */}
|
|
339
|
+
<Slot
|
|
340
|
+
name={`${formKey}.media.form_fields`}
|
|
341
|
+
context={{
|
|
342
|
+
productId,
|
|
343
|
+
media: entry,
|
|
344
|
+
mediaIndex,
|
|
345
|
+
fieldPrefix: `media.${mediaIndex}`,
|
|
346
|
+
onSave,
|
|
347
|
+
}}
|
|
348
|
+
/>
|
|
349
|
+
</MediaEditorSection>
|
|
350
|
+
|
|
351
|
+
{variants.length > 0 && (
|
|
352
|
+
<MediaEditorSection
|
|
353
|
+
icon={<LayersIcon />}
|
|
354
|
+
title={t('admin.products.media.assigned_variants_label')}
|
|
355
|
+
>
|
|
356
|
+
<p className="-mt-2 text-muted-foreground text-xs">
|
|
357
|
+
{t('admin.products.media.assigned_variants_help')}
|
|
358
|
+
</p>
|
|
359
|
+
<div className="flex flex-wrap gap-2">
|
|
360
|
+
{variants.map((v) => {
|
|
361
|
+
const id = v.id
|
|
362
|
+
if (!id) return null
|
|
363
|
+
const selected = selectedVariantIds.has(id)
|
|
364
|
+
const label = variantLabel(v)
|
|
365
|
+
return (
|
|
366
|
+
<button
|
|
367
|
+
key={id}
|
|
368
|
+
type="button"
|
|
369
|
+
onClick={() => toggleVariant(id)}
|
|
370
|
+
aria-pressed={selected}
|
|
371
|
+
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
|
|
372
|
+
selected
|
|
373
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
374
|
+
: 'border-border bg-background text-muted-foreground hover:border-muted-foreground hover:text-foreground'
|
|
375
|
+
}`}
|
|
376
|
+
>
|
|
377
|
+
{selected && <CheckIcon className="size-3" />}
|
|
378
|
+
{label}
|
|
379
|
+
</button>
|
|
380
|
+
)
|
|
381
|
+
})}
|
|
382
|
+
</div>
|
|
383
|
+
</MediaEditorSection>
|
|
384
|
+
)}
|
|
385
|
+
</MediaEditorDialog>
|
|
383
386
|
)
|
|
384
387
|
}
|