@spree/dashboard-core 0.13.0 → 1.0.0-beta.1
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/README.md +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +97 -36
- package/src/components/import-wizard-dialog.tsx +710 -0
- package/src/components/locale-select.tsx +70 -57
- package/src/components/media-picker-sheet.tsx +307 -0
- package/src/components/mobile-top-bar.tsx +75 -0
- package/src/components/nav-main.tsx +197 -27
- package/src/components/page-header.tsx +203 -61
- package/src/components/page-tabs.tsx +4 -1
- package/src/components/preferences-form.tsx +98 -4
- package/src/components/resource-combobox.tsx +52 -4
- package/src/components/resource-picker-sheet.tsx +209 -73
- package/src/components/resource-table.tsx +425 -131
- package/src/components/route-announcer.tsx +64 -0
- package/src/components/section-heading.tsx +95 -0
- package/src/components/settings-sidebar.tsx +290 -60
- package/src/components/sheet-top-bar.tsx +44 -0
- package/src/components/sidebar-search.tsx +83 -0
- package/src/components/sidebar-user.tsx +213 -0
- package/src/components/skip-link.tsx +36 -0
- package/src/components/store-date-picker.tsx +6 -2
- package/src/components/store-switcher.tsx +63 -22
- package/src/components/table-toolbar.tsx +1373 -442
- package/src/components/tag-combobox.tsx +12 -4
- package/src/components/top-bar-breadcrumbs.tsx +235 -0
- package/src/components/wizard-close-guard.test.ts +54 -0
- package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
- package/src/hooks/use-command-palette.tsx +13 -0
- package/src/hooks/use-countries.test.ts +56 -0
- package/src/hooks/use-countries.ts +51 -5
- package/src/hooks/use-direct-upload.ts +16 -3
- package/src/hooks/use-export.ts +51 -16
- package/src/hooks/use-import.ts +261 -14
- package/src/hooks/use-package-types.ts +132 -0
- package/src/hooks/use-resource-mutation.ts +74 -13
- package/src/hooks/use-stock-locations.ts +131 -0
- package/src/hooks/use-switch-admin-locale.ts +2 -2
- package/src/hooks/use-visual-viewport-offset.ts +61 -0
- package/src/index.ts +45 -1
- package/src/lib/cancel-order-form.ts +27 -0
- package/src/lib/create-action-registry.ts +278 -0
- package/src/lib/date-presets.test.ts +86 -0
- package/src/lib/date-presets.ts +98 -0
- package/src/lib/delivery-form.ts +15 -0
- package/src/lib/docs.ts +32 -0
- package/src/lib/download.ts +13 -1
- package/src/lib/filters-to-ransack.test.ts +113 -0
- package/src/lib/filters-to-ransack.ts +22 -1
- package/src/lib/form-errors.ts +122 -10
- package/src/lib/form-mappers.ts +76 -0
- package/src/lib/fulfillment-items.test.ts +213 -0
- package/src/lib/fulfillment-items.ts +171 -0
- package/src/lib/i18n.ts +19 -11
- package/src/lib/import-types.ts +25 -0
- package/src/lib/label-upload-form.ts +22 -0
- package/src/lib/nav-registry.ts +71 -3
- package/src/lib/permissions.ts +46 -1
- package/src/lib/progress.ts +18 -0
- package/src/lib/query-keys.ts +13 -13
- package/src/lib/sample-csv.ts +21 -0
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/storefront.ts +19 -0
- package/src/lib/table-registry.ts +56 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -516
- package/src/locales/de.json +1883 -515
- package/src/locales/en.json +1886 -516
- package/src/locales/fr.json +1884 -516
- package/src/locales/pl.json +1913 -516
- package/src/locales/zh-CN.json +1883 -516
- package/src/pages/package-types.tsx +484 -0
- package/src/pages/settings-index.tsx +112 -0
- package/src/pages/stock-locations.tsx +674 -0
- package/src/plugin.ts +29 -0
- package/src/products/add-video-dialog.tsx +100 -0
- package/src/products/bulk-variants-dialog.tsx +301 -0
- package/src/products/carton-size.test.ts +51 -0
- package/src/products/carton-size.ts +20 -0
- package/src/products/currency-parts.ts +21 -0
- package/src/products/inventory-section.tsx +259 -0
- package/src/products/media-edit-sheet.tsx +336 -0
- package/src/products/normalize-customs.test.ts +39 -0
- package/src/products/normalize-customs.ts +27 -0
- package/src/products/normalize-money.test.ts +58 -0
- package/src/products/normalize-money.ts +73 -0
- package/src/products/normalize-quantity.test.ts +32 -0
- package/src/products/normalize-quantity.ts +31 -0
- package/src/products/product-bulk-price-editor.tsx +165 -0
- package/src/products/product-form-cards.tsx +1167 -0
- package/src/products/product-form-mapping.ts +212 -0
- package/src/products/product-schema.ts +318 -0
- package/src/products/product-types.ts +130 -0
- package/src/products/use-product-form-data.ts +273 -0
- package/src/products/variant-edit-sheet.tsx +725 -0
- package/src/products/variants-matrix.test.ts +154 -0
- package/src/products/variants-matrix.ts +244 -0
- package/src/products/variants-options-builder.tsx +571 -0
- package/src/products/variants-section.tsx +535 -0
- package/src/products/video-url.test.ts +71 -0
- package/src/products/video-url.ts +83 -0
- package/src/providers/auth-provider.tsx +70 -12
- package/src/providers/permission-provider.tsx +68 -28
- package/src/providers/sticky-header-provider.tsx +134 -0
- package/src/providers/store-provider.tsx +36 -3
- package/src/providers/tenant-provider.tsx +33 -0
- package/src/schemas/package-type.ts +95 -0
- package/src/schemas/stock-location.ts +126 -0
- package/src/tables/package-types.tsx +74 -0
- package/src/tables/stock-locations.tsx +124 -0
- package/src/vite/index.ts +40 -7
- package/src/components/top-bar.tsx +0 -210
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Combobox,
|
|
3
|
+
ComboboxButtonTrigger,
|
|
3
4
|
ComboboxChip,
|
|
4
5
|
ComboboxChips,
|
|
5
6
|
ComboboxChipsInput,
|
|
6
7
|
ComboboxContent,
|
|
7
8
|
ComboboxEmpty,
|
|
8
|
-
ComboboxInput,
|
|
9
9
|
ComboboxItem,
|
|
10
10
|
ComboboxList,
|
|
11
|
+
ComboboxSearch,
|
|
12
|
+
ComboboxTriggerPlaceholder,
|
|
11
13
|
ComboboxValue,
|
|
12
|
-
Select,
|
|
13
|
-
SelectContent,
|
|
14
|
-
SelectItem,
|
|
15
|
-
SelectTrigger,
|
|
16
|
-
SelectValue,
|
|
17
14
|
useComboboxAnchor,
|
|
18
15
|
} from '@spree/dashboard-ui'
|
|
19
16
|
import { useMemo, useState } from 'react'
|
|
@@ -26,7 +23,12 @@ interface BaseProps {
|
|
|
26
23
|
options?: string[]
|
|
27
24
|
/** Locale to filter out (e.g. the default locale when picking additional supported ones). */
|
|
28
25
|
excludeCode?: string
|
|
29
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Announces the field as required to assistive technology (`aria-required`).
|
|
28
|
+
* It does not enforce anything: the trigger is a button, so there is no
|
|
29
|
+
* native constraint validation behind it. The consuming form owns
|
|
30
|
+
* enforcement.
|
|
31
|
+
*/
|
|
30
32
|
required?: boolean
|
|
31
33
|
disabled?: boolean
|
|
32
34
|
placeholder?: string
|
|
@@ -34,10 +36,23 @@ interface BaseProps {
|
|
|
34
36
|
id?: string
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Single-select only, by design — the multi-select deliberately does not
|
|
41
|
+
* support these. Its search box is the chips input itself rather than a box
|
|
42
|
+
* inside the popup, and its validity is a property of the whole selection
|
|
43
|
+
* rather than of one control. Declaring them here rather than on `BaseProps`
|
|
44
|
+
* makes passing them to a `multiple` picker a compile error instead of a prop
|
|
45
|
+
* that is silently dropped.
|
|
46
|
+
*/
|
|
37
47
|
interface SingleProps extends BaseProps {
|
|
38
48
|
multiple?: false
|
|
39
49
|
value: string | null | undefined
|
|
40
50
|
onChange: (locale: string) => void
|
|
51
|
+
/** Text in the dropdown's search box. */
|
|
52
|
+
searchPlaceholder?: string
|
|
53
|
+
invalid?: boolean
|
|
54
|
+
/** Forwarded to the trigger so RHF's `<Controller>` can track touched state. */
|
|
55
|
+
onBlur?: () => void
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
interface MultiProps extends BaseProps {
|
|
@@ -58,16 +73,16 @@ function useLocaleDisplayName() {
|
|
|
58
73
|
return (code: string) => displayName(code) ?? code
|
|
59
74
|
}
|
|
60
75
|
|
|
61
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Mirrors `CurrencySelect`'s row format: `CODE — Localized Name`. Codes the
|
|
78
|
+
* runtime cannot name (`Intl.DisplayNames` returns the code back, as it does
|
|
79
|
+
* for `dz`) render as the bare upper-cased code rather than `DZ — dz`.
|
|
80
|
+
*/
|
|
62
81
|
function formatOption(code: string, name: string) {
|
|
63
82
|
const upper = code.toUpperCase()
|
|
64
|
-
return name === code ? upper : `${upper} — ${name}`
|
|
83
|
+
return !name || name.toLowerCase() === code.toLowerCase() ? upper : `${upper} — ${name}`
|
|
65
84
|
}
|
|
66
85
|
|
|
67
|
-
/** Above this many options a plain `<Select>` is unwieldy — switch to a
|
|
68
|
-
* searchable combobox. Matches `CurrencySelect`. */
|
|
69
|
-
const SEARCHABLE_THRESHOLD = 12
|
|
70
|
-
|
|
71
86
|
/**
|
|
72
87
|
* Combobox `filter` factory shared by the single- and multi-select pickers:
|
|
73
88
|
* case-insensitive match on both the raw code and the localized `CODE — Name`
|
|
@@ -100,6 +115,11 @@ export function LocaleLabel({ code }: { code: string }) {
|
|
|
100
115
|
* `Intl.DisplayNames` against the store's `default_locale`. Use this anywhere
|
|
101
116
|
* the merchant is choosing a storefront language (market default, supported
|
|
102
117
|
* locales list, customer preference, etc.).
|
|
118
|
+
*
|
|
119
|
+
* The single-select field is a button showing the current locale, with the
|
|
120
|
+
* search box inside the dropdown; it is always searchable, whatever the list
|
|
121
|
+
* length, so the field behaves the same on a store with three locales as on
|
|
122
|
+
* one offering the full translatable set.
|
|
103
123
|
*/
|
|
104
124
|
export function LocaleSelect(props: LocaleSelectProps) {
|
|
105
125
|
const { t } = useTranslation()
|
|
@@ -153,52 +173,45 @@ export function LocaleSelect(props: LocaleSelectProps) {
|
|
|
153
173
|
const value = props.value ?? ''
|
|
154
174
|
const placeholder = props.placeholder ?? t('admin.components.locale_select.placeholder')
|
|
155
175
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
176
|
+
return (
|
|
177
|
+
<Combobox
|
|
178
|
+
items={items}
|
|
179
|
+
value={value}
|
|
180
|
+
onValueChange={(next: string | null) => props.onChange(next ?? '')}
|
|
181
|
+
itemToStringLabel={(locale: string | null) => (locale ? labelFor(locale) : '')}
|
|
182
|
+
itemToStringValue={(locale: string | null) => locale ?? ''}
|
|
183
|
+
filter={localeFilter(labelFor)}
|
|
184
|
+
disabled={props.disabled}
|
|
185
|
+
>
|
|
186
|
+
<ComboboxButtonTrigger
|
|
187
|
+
id={props.id}
|
|
188
|
+
onBlur={props.onBlur}
|
|
167
189
|
disabled={props.disabled}
|
|
190
|
+
aria-required={props.required}
|
|
191
|
+
aria-invalid={props.invalid || undefined}
|
|
168
192
|
>
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
{(v) => (v ? labelFor(v as string) : (v as string))}
|
|
192
|
-
</SelectValue>
|
|
193
|
-
</SelectTrigger>
|
|
194
|
-
<SelectContent>
|
|
195
|
-
{items.map((locale) => (
|
|
196
|
-
<SelectItem key={locale} value={locale}>
|
|
197
|
-
{labelFor(locale)}
|
|
198
|
-
</SelectItem>
|
|
199
|
-
))}
|
|
200
|
-
</SelectContent>
|
|
201
|
-
</Select>
|
|
193
|
+
{value ? (
|
|
194
|
+
<span className="truncate">{labelFor(value)}</span>
|
|
195
|
+
) : (
|
|
196
|
+
<ComboboxTriggerPlaceholder>{placeholder}</ComboboxTriggerPlaceholder>
|
|
197
|
+
)}
|
|
198
|
+
</ComboboxButtonTrigger>
|
|
199
|
+
<ComboboxContent>
|
|
200
|
+
<ComboboxSearch
|
|
201
|
+
placeholder={
|
|
202
|
+
props.searchPlaceholder ?? t('admin.components.locale_select.search_placeholder')
|
|
203
|
+
}
|
|
204
|
+
/>
|
|
205
|
+
<ComboboxEmpty>{t('admin.components.locale_select.empty')}</ComboboxEmpty>
|
|
206
|
+
<ComboboxList>
|
|
207
|
+
{(locale: string) => (
|
|
208
|
+
<ComboboxItem key={locale} value={locale}>
|
|
209
|
+
{labelFor(locale)}
|
|
210
|
+
</ComboboxItem>
|
|
211
|
+
)}
|
|
212
|
+
</ComboboxList>
|
|
213
|
+
</ComboboxContent>
|
|
214
|
+
</Combobox>
|
|
202
215
|
)
|
|
203
216
|
}
|
|
204
217
|
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
SearchInput,
|
|
4
|
+
Sheet,
|
|
5
|
+
SheetContent,
|
|
6
|
+
SheetDescription,
|
|
7
|
+
SheetFooter,
|
|
8
|
+
SheetHeader,
|
|
9
|
+
SheetTitle,
|
|
10
|
+
} from '@spree/dashboard-ui'
|
|
11
|
+
import { CheckIcon, ImageIcon, Loader2Icon, PlayIcon, UploadIcon } from '@spree/dashboard-ui/icons'
|
|
12
|
+
import { useQuery } from '@tanstack/react-query'
|
|
13
|
+
import { useDeferredValue, useRef, useState } from 'react'
|
|
14
|
+
import { useTranslation } from 'react-i18next'
|
|
15
|
+
import { useTenantId } from '../providers/tenant-provider'
|
|
16
|
+
|
|
17
|
+
/** The shape the picker needs; a full Media record satisfies it. */
|
|
18
|
+
export interface MediaPickerOption {
|
|
19
|
+
id: string
|
|
20
|
+
alt?: string | null
|
|
21
|
+
filename?: string | null
|
|
22
|
+
media_type?: string
|
|
23
|
+
small_url?: string | null
|
|
24
|
+
poster_url?: string | null
|
|
25
|
+
original_url?: string | null
|
|
26
|
+
/** Blob signed id, for handing a picked file to a plain attachment field. */
|
|
27
|
+
signed_id?: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MediaPickerSheetProps<T extends MediaPickerOption> {
|
|
31
|
+
open: boolean
|
|
32
|
+
onOpenChange: (open: boolean) => void
|
|
33
|
+
/** Paginated search over the library. Called with the trimmed query. */
|
|
34
|
+
search: (query: string) => Promise<{ data: T[] }>
|
|
35
|
+
/** Called with the picked files when the user confirms. */
|
|
36
|
+
onConfirm: (media: T[]) => void | Promise<void>
|
|
37
|
+
/** Uploads a file and returns the created library row, for the upload tab. */
|
|
38
|
+
onUpload?: (file: File) => Promise<T>
|
|
39
|
+
/** One file only — the picker confirms as soon as a tile is clicked. */
|
|
40
|
+
multiple?: boolean
|
|
41
|
+
/** Cache-isolation key, one per picker instance. */
|
|
42
|
+
queryKey: string
|
|
43
|
+
accept?: string
|
|
44
|
+
title?: string
|
|
45
|
+
description?: string
|
|
46
|
+
confirmLabel?: string
|
|
47
|
+
onConfirmError?: (error: unknown) => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Browse the store's media library and pick files from it.
|
|
52
|
+
*
|
|
53
|
+
* A grid rather than the row list {@link ResourcePickerSheet} uses: choosing a
|
|
54
|
+
* picture means looking at it, and a filename tells a merchant almost nothing
|
|
55
|
+
* about which photo it is.
|
|
56
|
+
*
|
|
57
|
+
* Headless — the caller supplies `search` and `onUpload`, so the same sheet
|
|
58
|
+
* serves the product media card (which places files on a product) and plain
|
|
59
|
+
* image fields (which take the picked file's blob).
|
|
60
|
+
*/
|
|
61
|
+
export function MediaPickerSheet<T extends MediaPickerOption>({
|
|
62
|
+
open,
|
|
63
|
+
onOpenChange,
|
|
64
|
+
search,
|
|
65
|
+
onConfirm,
|
|
66
|
+
onUpload,
|
|
67
|
+
multiple = true,
|
|
68
|
+
queryKey,
|
|
69
|
+
accept = 'image/png,image/jpeg,image/webp,image/gif',
|
|
70
|
+
title,
|
|
71
|
+
description,
|
|
72
|
+
confirmLabel,
|
|
73
|
+
onConfirmError,
|
|
74
|
+
}: MediaPickerSheetProps<T>) {
|
|
75
|
+
const { t } = useTranslation()
|
|
76
|
+
const tenantId = useTenantId()
|
|
77
|
+
|
|
78
|
+
const [input, setInput] = useState('')
|
|
79
|
+
const deferredInput = useDeferredValue(input)
|
|
80
|
+
const trimmedQuery = deferredInput.trim()
|
|
81
|
+
const [staged, setStaged] = useState<Map<string, T>>(new Map())
|
|
82
|
+
const [submitting, setSubmitting] = useState(false)
|
|
83
|
+
const [uploading, setUploading] = useState(false)
|
|
84
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
85
|
+
// Bumped whenever the sheet closes. An upload started in an earlier session
|
|
86
|
+
// must not confirm into a host that has already moved on — closing mid-upload
|
|
87
|
+
// would otherwise still set the field the merchant cancelled.
|
|
88
|
+
const sessionRef = useRef(0)
|
|
89
|
+
|
|
90
|
+
// The tenant id is part of the key: a library is one tenant's, and without
|
|
91
|
+
// it reopening the picker after a store or seller switch serves the previous
|
|
92
|
+
// tenant's files from cache — whose ids the new tenant's API would reject.
|
|
93
|
+
const { data, isFetching, refetch } = useQuery({
|
|
94
|
+
queryKey: [queryKey, 'media-picker', tenantId, trimmedQuery],
|
|
95
|
+
queryFn: () => search(trimmedQuery),
|
|
96
|
+
enabled: open,
|
|
97
|
+
staleTime: 30_000,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const results = data?.data ?? []
|
|
101
|
+
|
|
102
|
+
async function pick(option: T) {
|
|
103
|
+
if (!multiple) {
|
|
104
|
+
await submit([option])
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
setStaged((prev) => {
|
|
109
|
+
const next = new Map(prev)
|
|
110
|
+
if (next.has(option.id)) next.delete(option.id)
|
|
111
|
+
else next.set(option.id, option)
|
|
112
|
+
return next
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function submit(media: T[]) {
|
|
117
|
+
if (media.length === 0) return
|
|
118
|
+
setSubmitting(true)
|
|
119
|
+
try {
|
|
120
|
+
await onConfirm(media)
|
|
121
|
+
reset()
|
|
122
|
+
onOpenChange(false)
|
|
123
|
+
} catch (error) {
|
|
124
|
+
// The mutation shows its own error; keep the sheet open with the
|
|
125
|
+
// selection intact so the merchant can retry.
|
|
126
|
+
onConfirmError?.(error)
|
|
127
|
+
} finally {
|
|
128
|
+
setSubmitting(false)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function handleUpload(files: FileList | null) {
|
|
133
|
+
if (!files?.length || !onUpload) return
|
|
134
|
+
|
|
135
|
+
const session = sessionRef.current
|
|
136
|
+
setUploading(true)
|
|
137
|
+
try {
|
|
138
|
+
const uploaded: T[] = []
|
|
139
|
+
for (const file of Array.from(files)) {
|
|
140
|
+
uploaded.push(await onUpload(file))
|
|
141
|
+
}
|
|
142
|
+
// The file is in the library either way — only the confirmation is
|
|
143
|
+
// abandoned, so nothing the merchant uploaded is lost.
|
|
144
|
+
if (session !== sessionRef.current) return
|
|
145
|
+
|
|
146
|
+
await refetch()
|
|
147
|
+
|
|
148
|
+
if (multiple) {
|
|
149
|
+
setStaged((prev) => {
|
|
150
|
+
const next = new Map(prev)
|
|
151
|
+
for (const media of uploaded) next.set(media.id, media)
|
|
152
|
+
return next
|
|
153
|
+
})
|
|
154
|
+
} else {
|
|
155
|
+
await submit(uploaded.slice(0, 1))
|
|
156
|
+
}
|
|
157
|
+
} catch (error) {
|
|
158
|
+
onConfirmError?.(error)
|
|
159
|
+
} finally {
|
|
160
|
+
setUploading(false)
|
|
161
|
+
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function reset() {
|
|
166
|
+
setStaged(new Map())
|
|
167
|
+
setInput('')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function handleOpenChange(next: boolean) {
|
|
171
|
+
if (!next) {
|
|
172
|
+
sessionRef.current += 1
|
|
173
|
+
reset()
|
|
174
|
+
}
|
|
175
|
+
onOpenChange(next)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<Sheet open={open} onOpenChange={handleOpenChange}>
|
|
180
|
+
<SheetContent className="w-full gap-0 p-0 sm:max-w-2xl">
|
|
181
|
+
<SheetHeader>
|
|
182
|
+
<SheetTitle>{title ?? t('admin.media_picker.title')}</SheetTitle>
|
|
183
|
+
<SheetDescription>{description ?? t('admin.media_picker.description')}</SheetDescription>
|
|
184
|
+
</SheetHeader>
|
|
185
|
+
|
|
186
|
+
<div className="flex items-center gap-2 border-b border-border-subtle p-4">
|
|
187
|
+
<SearchInput
|
|
188
|
+
value={input}
|
|
189
|
+
onValueChange={setInput}
|
|
190
|
+
// Enter has no action here; swallow it so it can't submit a host form.
|
|
191
|
+
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
|
|
192
|
+
placeholder={t('admin.media_picker.search_placeholder')}
|
|
193
|
+
clearLabel={t('admin.common.clear')}
|
|
194
|
+
className="flex-1"
|
|
195
|
+
/>
|
|
196
|
+
{onUpload && (
|
|
197
|
+
<>
|
|
198
|
+
<input
|
|
199
|
+
ref={fileInputRef}
|
|
200
|
+
type="file"
|
|
201
|
+
accept={accept}
|
|
202
|
+
multiple={multiple}
|
|
203
|
+
className="hidden"
|
|
204
|
+
onChange={(e) => handleUpload(e.target.files)}
|
|
205
|
+
/>
|
|
206
|
+
<Button
|
|
207
|
+
type="button"
|
|
208
|
+
variant="outline"
|
|
209
|
+
disabled={uploading}
|
|
210
|
+
onClick={() => fileInputRef.current?.click()}
|
|
211
|
+
>
|
|
212
|
+
{uploading ? (
|
|
213
|
+
<Loader2Icon className="size-4 animate-spin" />
|
|
214
|
+
) : (
|
|
215
|
+
<UploadIcon className="size-4" />
|
|
216
|
+
)}
|
|
217
|
+
{t('admin.media_picker.upload')}
|
|
218
|
+
</Button>
|
|
219
|
+
</>
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
|
224
|
+
{isFetching && results.length === 0 ? (
|
|
225
|
+
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
|
226
|
+
<Loader2Icon className="size-5 animate-spin" />
|
|
227
|
+
</div>
|
|
228
|
+
) : results.length === 0 ? (
|
|
229
|
+
<p className="p-6 text-center text-muted-foreground text-sm">
|
|
230
|
+
{trimmedQuery ? t('admin.media_picker.no_results') : t('admin.media_picker.empty')}
|
|
231
|
+
</p>
|
|
232
|
+
) : (
|
|
233
|
+
<ul className="grid grid-cols-3 gap-3 sm:grid-cols-4">
|
|
234
|
+
{results.map((media) => {
|
|
235
|
+
const checked = staged.has(media.id)
|
|
236
|
+
const thumbnail = media.small_url ?? media.poster_url ?? media.original_url
|
|
237
|
+
const label = media.alt || media.filename || ''
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<li key={media.id}>
|
|
241
|
+
<button
|
|
242
|
+
type="button"
|
|
243
|
+
onClick={() => pick(media)}
|
|
244
|
+
aria-pressed={checked}
|
|
245
|
+
className={`group relative block w-full overflow-hidden rounded-md border transition-colors ${
|
|
246
|
+
checked
|
|
247
|
+
? 'border-primary ring-2 ring-primary'
|
|
248
|
+
: 'border-border hover:border-muted-foreground'
|
|
249
|
+
}`}
|
|
250
|
+
>
|
|
251
|
+
<span className="flex aspect-square items-center justify-center bg-muted">
|
|
252
|
+
{thumbnail ? (
|
|
253
|
+
<img
|
|
254
|
+
src={thumbnail}
|
|
255
|
+
alt={label}
|
|
256
|
+
loading="lazy"
|
|
257
|
+
className="size-full object-cover"
|
|
258
|
+
/>
|
|
259
|
+
) : (
|
|
260
|
+
<ImageIcon className="size-6 text-muted-foreground" />
|
|
261
|
+
)}
|
|
262
|
+
</span>
|
|
263
|
+
|
|
264
|
+
{media.media_type !== 'image' && (
|
|
265
|
+
<span className="absolute top-1.5 left-1.5 rounded bg-black/60 p-1 text-white">
|
|
266
|
+
<PlayIcon className="size-3" />
|
|
267
|
+
</span>
|
|
268
|
+
)}
|
|
269
|
+
|
|
270
|
+
{checked && (
|
|
271
|
+
<span className="absolute top-1.5 right-1.5 rounded-full bg-primary p-1 text-primary-foreground">
|
|
272
|
+
<CheckIcon className="size-3" />
|
|
273
|
+
</span>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
{label && (
|
|
277
|
+
<span className="block truncate px-2 py-1.5 text-left text-xs">
|
|
278
|
+
{label}
|
|
279
|
+
</span>
|
|
280
|
+
)}
|
|
281
|
+
</button>
|
|
282
|
+
</li>
|
|
283
|
+
)
|
|
284
|
+
})}
|
|
285
|
+
</ul>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
{multiple && (
|
|
290
|
+
<SheetFooter className="flex-row items-center justify-between border-t border-border">
|
|
291
|
+
<span className="text-muted-foreground text-xs">
|
|
292
|
+
{t('admin.media_picker.selected_count', { count: staged.size })}
|
|
293
|
+
</span>
|
|
294
|
+
<Button
|
|
295
|
+
type="button"
|
|
296
|
+
disabled={staged.size === 0 || submitting}
|
|
297
|
+
onClick={() => submit(Array.from(staged.values()))}
|
|
298
|
+
>
|
|
299
|
+
{submitting && <Loader2Icon className="size-4 animate-spin" />}
|
|
300
|
+
{confirmLabel ?? t('admin.media_picker.confirm')}
|
|
301
|
+
</Button>
|
|
302
|
+
</SheetFooter>
|
|
303
|
+
)}
|
|
304
|
+
</SheetContent>
|
|
305
|
+
</Sheet>
|
|
306
|
+
)
|
|
307
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { SidebarTrigger, Skeleton } from '@spree/dashboard-ui'
|
|
2
|
+
import { SearchIcon } from '@spree/dashboard-ui/icons'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
import { useOptionalCommandPalette } from '../hooks/use-command-palette'
|
|
5
|
+
import { useOptionalStore } from '../providers/store-provider'
|
|
6
|
+
import { SidebarUser } from './sidebar-user'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The bar a phone gets in place of the nav rail.
|
|
10
|
+
*
|
|
11
|
+
* On a narrow screen the rail is an off-canvas drawer, so something on the
|
|
12
|
+
* page has to open it — and after the shell moved the account menu and the
|
|
13
|
+
* search box into the rail, that trigger is the only way to reach either of
|
|
14
|
+
* them. Without this bar the drawer has no opener at all and the nav is
|
|
15
|
+
* simply unreachable, which is why it renders unconditionally rather than
|
|
16
|
+
* only where a breadcrumb trail exists.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately thin: the trigger, the store it belongs to, and search. Page
|
|
19
|
+
* titles and actions stay with `PageHeader` below, so this never becomes a
|
|
20
|
+
* second place to look for them.
|
|
21
|
+
*/
|
|
22
|
+
export function MobileTopBar({
|
|
23
|
+
uiLocales = [],
|
|
24
|
+
onEditProfile,
|
|
25
|
+
title,
|
|
26
|
+
}: {
|
|
27
|
+
uiLocales?: ReadonlyArray<{ code: string; name: string }>
|
|
28
|
+
onEditProfile?: () => void
|
|
29
|
+
/** Name shown beside the controls. Defaults to the store's, for panels that have one. */
|
|
30
|
+
title?: string
|
|
31
|
+
} = {}) {
|
|
32
|
+
const { t } = useTranslation()
|
|
33
|
+
// `useOptionalStore` rather than `useStore`: the seller panel mounts this
|
|
34
|
+
// under a `TenantProvider` with no store at all, and a hard `useStore`
|
|
35
|
+
// throws there — taking every seller page down at phone width. A panel
|
|
36
|
+
// without a store passes its own `title` instead.
|
|
37
|
+
const storeContext = useOptionalStore()
|
|
38
|
+
// Absent in panels that mount no palette (the seller panel), where the
|
|
39
|
+
// search button would open nothing.
|
|
40
|
+
const palette = useOptionalCommandPalette()
|
|
41
|
+
|
|
42
|
+
const label = title ?? storeContext?.store?.name
|
|
43
|
+
// Only a store-backed panel has a name still loading; one that passes its
|
|
44
|
+
// own title has nothing to wait for.
|
|
45
|
+
const isLoading = title === undefined && (storeContext?.isLoading ?? false)
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
// `h-14` (56px) is the phone top-bar height on Android, and comfortably
|
|
49
|
+
// clears the 44pt iOS navigation bar; the controls inside are `size-11`
|
|
50
|
+
// (44px), the touch target both platforms ask for and the size this
|
|
51
|
+
// codebase already uses for mobile controls in the table toolbar.
|
|
52
|
+
<div className="sticky top-0 z-30 flex h-14 shrink-0 items-center gap-1 border-b border-border bg-card px-2 md:hidden">
|
|
53
|
+
<SidebarTrigger className="size-11 shrink-0 text-muted-foreground hover:bg-accent hover:text-foreground" />
|
|
54
|
+
|
|
55
|
+
{isLoading ? (
|
|
56
|
+
<Skeleton className="h-4 w-28" />
|
|
57
|
+
) : (
|
|
58
|
+
<span className="min-w-0 flex-1 truncate font-medium text-sm">{label}</span>
|
|
59
|
+
)}
|
|
60
|
+
|
|
61
|
+
{palette && (
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
onClick={() => palette.setOpen(true)}
|
|
65
|
+
aria-label={t('admin.components.command_palette.search_label')}
|
|
66
|
+
className="inline-flex size-11 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-100 hover:bg-accent hover:text-foreground"
|
|
67
|
+
>
|
|
68
|
+
<SearchIcon className="size-5" />
|
|
69
|
+
</button>
|
|
70
|
+
)}
|
|
71
|
+
|
|
72
|
+
<SidebarUser variant="bar" uiLocales={uiLocales} onEditProfile={onEditProfile} />
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|