@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.
Files changed (126) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +97 -36
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +425 -131
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +63 -22
  36. package/src/components/table-toolbar.tsx +1373 -442
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +261 -14
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +21 -0
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/storefront.ts +19 -0
  77. package/src/lib/table-registry.ts +56 -0
  78. package/src/lib/type-labels.test.ts +68 -0
  79. package/src/lib/type-labels.ts +78 -0
  80. package/src/locales/ar.json +1889 -516
  81. package/src/locales/de.json +1883 -515
  82. package/src/locales/en.json +1886 -516
  83. package/src/locales/fr.json +1884 -516
  84. package/src/locales/pl.json +1913 -516
  85. package/src/locales/zh-CN.json +1883 -516
  86. package/src/pages/package-types.tsx +484 -0
  87. package/src/pages/settings-index.tsx +112 -0
  88. package/src/pages/stock-locations.tsx +674 -0
  89. package/src/plugin.ts +29 -0
  90. package/src/products/add-video-dialog.tsx +100 -0
  91. package/src/products/bulk-variants-dialog.tsx +301 -0
  92. package/src/products/carton-size.test.ts +51 -0
  93. package/src/products/carton-size.ts +20 -0
  94. package/src/products/currency-parts.ts +21 -0
  95. package/src/products/inventory-section.tsx +259 -0
  96. package/src/products/media-edit-sheet.tsx +336 -0
  97. package/src/products/normalize-customs.test.ts +39 -0
  98. package/src/products/normalize-customs.ts +27 -0
  99. package/src/products/normalize-money.test.ts +58 -0
  100. package/src/products/normalize-money.ts +73 -0
  101. package/src/products/normalize-quantity.test.ts +32 -0
  102. package/src/products/normalize-quantity.ts +31 -0
  103. package/src/products/product-bulk-price-editor.tsx +165 -0
  104. package/src/products/product-form-cards.tsx +1167 -0
  105. package/src/products/product-form-mapping.ts +212 -0
  106. package/src/products/product-schema.ts +318 -0
  107. package/src/products/product-types.ts +130 -0
  108. package/src/products/use-product-form-data.ts +273 -0
  109. package/src/products/variant-edit-sheet.tsx +725 -0
  110. package/src/products/variants-matrix.test.ts +154 -0
  111. package/src/products/variants-matrix.ts +244 -0
  112. package/src/products/variants-options-builder.tsx +571 -0
  113. package/src/products/variants-section.tsx +535 -0
  114. package/src/products/video-url.test.ts +71 -0
  115. package/src/products/video-url.ts +83 -0
  116. package/src/providers/auth-provider.tsx +70 -12
  117. package/src/providers/permission-provider.tsx +68 -28
  118. package/src/providers/sticky-header-provider.tsx +134 -0
  119. package/src/providers/store-provider.tsx +36 -3
  120. package/src/providers/tenant-provider.tsx +33 -0
  121. package/src/schemas/package-type.ts +95 -0
  122. package/src/schemas/stock-location.ts +126 -0
  123. package/src/tables/package-types.tsx +74 -0
  124. package/src/tables/stock-locations.tsx +124 -0
  125. package/src/vite/index.ts +40 -7
  126. 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
- /** Marks the field required for screen readers + native form submission. */
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
- /** Mirrors `CurrencySelect`'s row format: `CODE — Localized Name`. */
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
- // Long lists (e.g. the full set of translatable locales) are unusable as a
157
- // plain dropdown — switch to a searchable combobox, matching `CurrencySelect`.
158
- if (items.length > SEARCHABLE_THRESHOLD) {
159
- return (
160
- <Combobox
161
- items={items}
162
- value={value}
163
- onValueChange={(next: string | null) => props.onChange(next ?? '')}
164
- itemToStringLabel={(locale: string | null) => (locale ? labelFor(locale) : '')}
165
- itemToStringValue={(locale: string | null) => locale ?? ''}
166
- filter={localeFilter(labelFor)}
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
- <ComboboxInput id={props.id} aria-required={props.required} placeholder={placeholder} />
170
- <ComboboxContent>
171
- <ComboboxEmpty>{t('admin.components.locale_select.empty')}</ComboboxEmpty>
172
- <ComboboxList>
173
- {(locale: string) => (
174
- <ComboboxItem key={locale} value={locale}>
175
- {labelFor(locale)}
176
- </ComboboxItem>
177
- )}
178
- </ComboboxList>
179
- </ComboboxContent>
180
- </Combobox>
181
- )
182
- }
183
-
184
- return (
185
- <Select value={value} onValueChange={props.onChange} disabled={props.disabled}>
186
- <SelectTrigger id={props.id} aria-required={props.required}>
187
- {/* Base UI's `<SelectValue>` defaults to the raw locale code. Use
188
- the children render-prop so the trigger shows the same
189
- `CODE — Name` as the items. */}
190
- <SelectValue placeholder={placeholder}>
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
+ }