@spree/dashboard-core 0.12.0 → 0.13.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/package.json +3 -3
- package/src/components/import-button.tsx +48 -12
- package/src/components/preferences-form.tsx +14 -0
- package/src/components/resource-table.tsx +45 -7
- package/src/components/store-switcher.tsx +51 -15
- package/src/components/table-toolbar.tsx +17 -3
- package/src/components/top-bar.tsx +3 -4
- package/src/hooks/use-import.ts +22 -0
- package/src/lib/permissions.ts +1 -0
- package/src/lib/sample-csv.ts +18 -0
- package/src/lib/storefront.ts +19 -0
- package/src/lib/table-registry.ts +6 -0
- package/src/locales/ar.json +4 -2
- package/src/locales/de.json +4 -2
- package/src/locales/en.json +6 -2
- package/src/locales/fr.json +4 -2
- package/src/locales/pl.json +4 -2
- package/src/locales/zh-CN.json +4 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spree/dashboard-core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.13.1",
|
|
5
5
|
"description": "Spree Dashboard framework — registries (table, nav, slot, settings-nav), providers (auth, permission, store), admin SDK client, generic infra hooks, and the defineDashboardPlugin extension API. Developer Preview.",
|
|
6
6
|
"author": "Vendo Connect Inc.",
|
|
7
7
|
"homepage": "https://spreecommerce.org",
|
|
@@ -79,8 +79,8 @@
|
|
|
79
79
|
"typescript": "~6.0.3",
|
|
80
80
|
"vite": "^8.0.16",
|
|
81
81
|
"vitest": "^4.1.5",
|
|
82
|
-
"@spree/admin-sdk": "^0.
|
|
83
|
-
"@spree/dashboard-ui": "^0.
|
|
82
|
+
"@spree/admin-sdk": "^0.8.1",
|
|
83
|
+
"@spree/dashboard-ui": "^0.13.1"
|
|
84
84
|
},
|
|
85
85
|
"scripts": {
|
|
86
86
|
"lint": "biome check src",
|
|
@@ -19,8 +19,13 @@ import { DownloadIcon, FileSpreadsheetIcon, UploadIcon } from 'lucide-react'
|
|
|
19
19
|
import { useState } from 'react'
|
|
20
20
|
import { useTranslation } from 'react-i18next'
|
|
21
21
|
import { toast } from 'sonner'
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
useCreateImport,
|
|
24
|
+
useDownloadImportExample,
|
|
25
|
+
useDownloadImportTemplate,
|
|
26
|
+
} from '../hooks/use-import'
|
|
23
27
|
import type { SubjectName } from '../lib/permissions'
|
|
28
|
+
import { hasSampleCsv } from '../lib/sample-csv'
|
|
24
29
|
import { Can } from './can'
|
|
25
30
|
import { EMPTY_FILE_UPLOAD_VALUE, FileUploadField, type FileUploadValue } from './file-upload-field'
|
|
26
31
|
|
|
@@ -65,6 +70,8 @@ export function ImportButton({ type, subject, onCreated, label }: ImportButtonPr
|
|
|
65
70
|
const [delimiter, setDelimiter] = useState<Delimiter>(',')
|
|
66
71
|
const createImport = useCreateImport()
|
|
67
72
|
const downloadTemplate = useDownloadImportTemplate()
|
|
73
|
+
const downloadExample = useDownloadImportExample()
|
|
74
|
+
const hasExample = hasSampleCsv(type)
|
|
68
75
|
|
|
69
76
|
const delimiterOptions = DELIMITERS.map(({ value, labelKey }) => ({
|
|
70
77
|
value,
|
|
@@ -109,6 +116,18 @@ export function ImportButton({ type, subject, onCreated, label }: ImportButtonPr
|
|
|
109
116
|
})
|
|
110
117
|
}
|
|
111
118
|
|
|
119
|
+
function handleExampleDownload() {
|
|
120
|
+
downloadExample.mutate(type, {
|
|
121
|
+
onError: (err) => {
|
|
122
|
+
toast.error(
|
|
123
|
+
t('admin.components.import_button.example_failed', {
|
|
124
|
+
message: err instanceof Error ? err.message : String(err),
|
|
125
|
+
}),
|
|
126
|
+
)
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
112
131
|
return (
|
|
113
132
|
<Can I="create" a={subject}>
|
|
114
133
|
<Button
|
|
@@ -162,17 +181,34 @@ export function ImportButton({ type, subject, onCreated, label }: ImportButtonPr
|
|
|
162
181
|
</Select>
|
|
163
182
|
</Field>
|
|
164
183
|
|
|
165
|
-
<
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
184
|
+
<div className="flex flex-col items-start gap-1">
|
|
185
|
+
<Button
|
|
186
|
+
type="button"
|
|
187
|
+
variant="ghost"
|
|
188
|
+
size="sm"
|
|
189
|
+
onClick={handleTemplateDownload}
|
|
190
|
+
disabled={downloadTemplate.isPending}
|
|
191
|
+
>
|
|
192
|
+
<DownloadIcon className="size-4" />
|
|
193
|
+
{t('admin.components.import_button.download_template')}
|
|
194
|
+
</Button>
|
|
195
|
+
|
|
196
|
+
{/* The populated counterpart to the headers-only template above.
|
|
197
|
+
Fetched through the API so the file matches the installed
|
|
198
|
+
Spree version's schema. */}
|
|
199
|
+
{hasExample && (
|
|
200
|
+
<Button
|
|
201
|
+
type="button"
|
|
202
|
+
variant="ghost"
|
|
203
|
+
size="sm"
|
|
204
|
+
onClick={handleExampleDownload}
|
|
205
|
+
disabled={downloadExample.isPending}
|
|
206
|
+
>
|
|
207
|
+
<FileSpreadsheetIcon className="size-4" />
|
|
208
|
+
{t('admin.components.import_button.download_example')}
|
|
209
|
+
</Button>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
176
212
|
</div>
|
|
177
213
|
|
|
178
214
|
<SheetFooter>
|
|
@@ -173,6 +173,11 @@ export function PreferenceField({
|
|
|
173
173
|
id={id}
|
|
174
174
|
type="number"
|
|
175
175
|
step={field.type === 'integer' ? 1 : 'any'}
|
|
176
|
+
// An empty upper-bound field means "no limit" — surface that
|
|
177
|
+
// rather than leaving it looking like a required blank.
|
|
178
|
+
placeholder={
|
|
179
|
+
isMaxKey(field.key) ? t('admin.components.preferences_form.unlimited') : undefined
|
|
180
|
+
}
|
|
176
181
|
value={value === null || value === undefined ? '' : String(value)}
|
|
177
182
|
onChange={(e) => {
|
|
178
183
|
const raw = e.target.value
|
|
@@ -247,6 +252,15 @@ function isCurrencyKey(key: string): boolean {
|
|
|
247
252
|
return key === 'currency' || key.endsWith('_currency')
|
|
248
253
|
}
|
|
249
254
|
|
|
255
|
+
// Upper-bound preferences (`max_quantity`, `max_uses`, `usage_max`,
|
|
256
|
+
// `maximum_amount`, …) read "Unlimited" when left blank. Matched on the key
|
|
257
|
+
// name because the wire schema doesn't carry a nullable flag — safe since the
|
|
258
|
+
// placeholder only shows for an empty value, and an empty max legitimately
|
|
259
|
+
// means no ceiling.
|
|
260
|
+
function isMaxKey(key: string): boolean {
|
|
261
|
+
return /(?:^|_)max(?:imum)?(?:_|$)/.test(key)
|
|
262
|
+
}
|
|
263
|
+
|
|
250
264
|
interface TierRowState {
|
|
251
265
|
/** Stable across edits — used for React keys so input focus survives reorder. */
|
|
252
266
|
uid: string
|
|
@@ -57,7 +57,6 @@ import {
|
|
|
57
57
|
type ColumnDef,
|
|
58
58
|
type FilterRule,
|
|
59
59
|
getDefaultColumnKeys,
|
|
60
|
-
getDisplayableColumns,
|
|
61
60
|
getTable,
|
|
62
61
|
type SortOption,
|
|
63
62
|
} from '../lib/table-registry'
|
|
@@ -174,6 +173,14 @@ interface ResourceTableProps<T> {
|
|
|
174
173
|
* menu on the trailing edge.
|
|
175
174
|
*/
|
|
176
175
|
rowActions?: (row: T) => ReactNode
|
|
176
|
+
/**
|
|
177
|
+
* Dynamic per-store columns derived from custom field definitions,
|
|
178
|
+
* merged into the registry columns for display (column selector +
|
|
179
|
+
* cells), sorting, and filtering. Keys are the definitions'
|
|
180
|
+
* `filter_key` (`cf_*`), which the backend accepts as sort and
|
|
181
|
+
* filter attributes.
|
|
182
|
+
*/
|
|
183
|
+
metafieldColumns?: ColumnDef<T>[]
|
|
177
184
|
}
|
|
178
185
|
|
|
179
186
|
export interface ReorderConfig<T> {
|
|
@@ -197,6 +204,7 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
197
204
|
reorder,
|
|
198
205
|
bulkActions,
|
|
199
206
|
rowActions,
|
|
207
|
+
metafieldColumns,
|
|
200
208
|
}: ResourceTableProps<T>) {
|
|
201
209
|
const table = getTable<T>(tableKey)
|
|
202
210
|
const { t } = useTranslation()
|
|
@@ -236,11 +244,31 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
236
244
|
const [searchInput, setSearchInput] = useState(search ?? '')
|
|
237
245
|
const deferredSearch = useDeferredValue(searchInput)
|
|
238
246
|
|
|
239
|
-
|
|
247
|
+
// Registry columns + dynamic metafield columns. Memoized so the toolbar's
|
|
248
|
+
// internal memos (FilterPanel `items` stability) keep stable deps.
|
|
249
|
+
const allColumns = useMemo(
|
|
250
|
+
() => [...table.columns, ...(metafieldColumns ?? [])],
|
|
251
|
+
[table, metafieldColumns],
|
|
252
|
+
)
|
|
253
|
+
const displayableColumns = useMemo(
|
|
254
|
+
() => allColumns.filter((c) => c.displayable !== false),
|
|
255
|
+
[allColumns],
|
|
256
|
+
)
|
|
240
257
|
const defaultColumnKeys = getDefaultColumnKeys(table)
|
|
241
258
|
const visibleColumnKeys = urlColumns ?? defaultColumnKeys
|
|
242
259
|
|
|
243
|
-
const visibleColumns =
|
|
260
|
+
const visibleColumns = useMemo(
|
|
261
|
+
() => displayableColumns.filter((c) => visibleColumnKeys.includes(c.key)),
|
|
262
|
+
[displayableColumns, visibleColumnKeys],
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
// Expands requested by currently-visible columns (e.g. custom field
|
|
266
|
+
// columns need `expand=custom_fields` to render their values). Sorted so
|
|
267
|
+
// the query key stays stable regardless of column order.
|
|
268
|
+
const columnExpand = useMemo(
|
|
269
|
+
() => [...new Set(visibleColumns.flatMap((c) => (c.expand ? [c.expand] : [])))].sort(),
|
|
270
|
+
[visibleColumns],
|
|
271
|
+
)
|
|
244
272
|
|
|
245
273
|
// Build API params
|
|
246
274
|
const sortString = dir === 'desc' ? `-${sort}` : sort
|
|
@@ -256,7 +284,7 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
256
284
|
const { data, isLoading } = useQuery({
|
|
257
285
|
queryKey: [
|
|
258
286
|
...queryKeyPrefix,
|
|
259
|
-
{ page, limit, sort: sortString, search: deferredSearch, filters },
|
|
287
|
+
{ page, limit, sort: sortString, search: deferredSearch, filters, expand: columnExpand },
|
|
260
288
|
],
|
|
261
289
|
queryFn: () => {
|
|
262
290
|
const params: Record<string, unknown> = {
|
|
@@ -269,12 +297,22 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
269
297
|
params.limit = limit
|
|
270
298
|
}
|
|
271
299
|
|
|
300
|
+
if (columnExpand.length) {
|
|
301
|
+
const base = defaultParams?.expand
|
|
302
|
+
const baseList = Array.isArray(base)
|
|
303
|
+
? base
|
|
304
|
+
: typeof base === 'string'
|
|
305
|
+
? base.split(',')
|
|
306
|
+
: []
|
|
307
|
+
params.expand = [...new Set([...baseList, ...columnExpand])]
|
|
308
|
+
}
|
|
309
|
+
|
|
272
310
|
if (deferredSearch) {
|
|
273
311
|
const searchParam = table.searchParam ?? 'name_cont'
|
|
274
312
|
params[searchParam] = deferredSearch
|
|
275
313
|
}
|
|
276
314
|
|
|
277
|
-
Object.assign(params, filtersToRansack(filters as FilterRule[],
|
|
315
|
+
Object.assign(params, filtersToRansack(filters as FilterRule[], allColumns))
|
|
278
316
|
|
|
279
317
|
return queryFn(params)
|
|
280
318
|
},
|
|
@@ -409,7 +447,7 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
409
447
|
filters: filters as FilterRule[],
|
|
410
448
|
search: deferredSearch,
|
|
411
449
|
searchParam: table.searchParam ?? 'name_cont',
|
|
412
|
-
columns:
|
|
450
|
+
columns: allColumns,
|
|
413
451
|
totalCount: meta?.count,
|
|
414
452
|
})
|
|
415
453
|
: actions
|
|
@@ -427,7 +465,7 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
427
465
|
onSortChange={handleSortChange}
|
|
428
466
|
filters={filters as FilterRule[]}
|
|
429
467
|
onFiltersChange={handleFiltersChange}
|
|
430
|
-
allColumns={
|
|
468
|
+
allColumns={allColumns}
|
|
431
469
|
title={title ?? table.title}
|
|
432
470
|
actions={resolvedActions}
|
|
433
471
|
hideSort={reorderActive}
|
|
@@ -11,24 +11,59 @@ import {
|
|
|
11
11
|
Skeleton,
|
|
12
12
|
useSidebar,
|
|
13
13
|
} from '@spree/dashboard-ui'
|
|
14
|
-
import {
|
|
14
|
+
import { Link } from '@tanstack/react-router'
|
|
15
|
+
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
|
|
15
16
|
import { useTranslation } from 'react-i18next'
|
|
17
|
+
import { useAuth } from '../hooks/use-auth'
|
|
16
18
|
import { useStore } from '../providers/store-provider'
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Sidebar store header. Renders a plain, non-interactive block when the
|
|
22
|
+
* signed-in admin only has access to one store; becomes a dropdown listing
|
|
23
|
+
* every store they hold a role on (from `user.stores`) once there are two
|
|
24
|
+
* or more to switch between.
|
|
25
|
+
*/
|
|
18
26
|
export function StoreSwitcher() {
|
|
19
27
|
const { t } = useTranslation()
|
|
20
28
|
const { isMobile, state } = useSidebar()
|
|
21
29
|
const isCollapsed = state === 'collapsed'
|
|
22
30
|
|
|
23
31
|
const { store, isLoading } = useStore()
|
|
32
|
+
const { user } = useAuth()
|
|
24
33
|
|
|
25
34
|
if (isLoading) return <Skeleton className="h-header-height w-full rounded-xl" />
|
|
26
35
|
|
|
36
|
+
const stores = user?.stores ?? []
|
|
37
|
+
|
|
27
38
|
const storeInitials = store?.name
|
|
28
39
|
.split(' ')
|
|
29
40
|
.map((name) => name[0])
|
|
30
41
|
.join('')
|
|
31
42
|
|
|
43
|
+
const header = (
|
|
44
|
+
<>
|
|
45
|
+
<Avatar>
|
|
46
|
+
{store?.logo_url && <AvatarImage src={store.logo_url} />}
|
|
47
|
+
<AvatarFallback>{storeInitials}</AvatarFallback>
|
|
48
|
+
</Avatar>
|
|
49
|
+
{!isCollapsed && (
|
|
50
|
+
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
51
|
+
<span className="truncate font-medium text-foreground">{store?.name}</span>
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
</>
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if (stores.length < 2) {
|
|
58
|
+
return (
|
|
59
|
+
<SidebarMenu>
|
|
60
|
+
<SidebarMenuItem className="h-header-height flex items-center">
|
|
61
|
+
<div className="flex w-full items-center gap-2 p-1.5">{header}</div>
|
|
62
|
+
</SidebarMenuItem>
|
|
63
|
+
</SidebarMenu>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
32
67
|
return (
|
|
33
68
|
<SidebarMenu>
|
|
34
69
|
<SidebarMenuItem className="h-header-height flex items-center">
|
|
@@ -36,19 +71,12 @@ export function StoreSwitcher() {
|
|
|
36
71
|
<DropdownMenuTrigger asChild className="flex w-full items-center">
|
|
37
72
|
<button
|
|
38
73
|
type="button"
|
|
74
|
+
aria-label={t('admin.a11y.store_switcher')}
|
|
39
75
|
className="rounded-xl outline-hidden transition-colors duration-100 hover:bg-sidebar-accent data-[state=open]:bg-sidebar-accent gap-2 p-1.5"
|
|
40
76
|
>
|
|
41
|
-
|
|
42
|
-
{store?.logo_url && <AvatarImage src={store.logo_url} />}
|
|
43
|
-
<AvatarFallback>{storeInitials}</AvatarFallback>
|
|
44
|
-
</Avatar>
|
|
77
|
+
{header}
|
|
45
78
|
{!isCollapsed && (
|
|
46
|
-
|
|
47
|
-
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
48
|
-
<span className="truncate font-medium text-foreground">{store?.name}</span>
|
|
49
|
-
</div>
|
|
50
|
-
<ChevronsUpDownIcon className="ml-auto size-4 text-muted-foreground" />
|
|
51
|
-
</>
|
|
79
|
+
<ChevronsUpDownIcon className="ml-auto size-4 text-muted-foreground" />
|
|
52
80
|
)}
|
|
53
81
|
</button>
|
|
54
82
|
</DropdownMenuTrigger>
|
|
@@ -58,10 +86,18 @@ export function StoreSwitcher() {
|
|
|
58
86
|
align="start"
|
|
59
87
|
sideOffset={8}
|
|
60
88
|
>
|
|
61
|
-
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
89
|
+
{stores.map((availableStore) => (
|
|
90
|
+
<DropdownMenuItem key={availableStore.id} asChild>
|
|
91
|
+
<Link
|
|
92
|
+
to="/$storeId"
|
|
93
|
+
params={{ storeId: availableStore.id }}
|
|
94
|
+
className="no-underline"
|
|
95
|
+
>
|
|
96
|
+
<span className="flex-1 truncate">{availableStore.name}</span>
|
|
97
|
+
{availableStore.id === store?.id && <CheckIcon className="size-4" />}
|
|
98
|
+
</Link>
|
|
99
|
+
</DropdownMenuItem>
|
|
100
|
+
))}
|
|
65
101
|
</DropdownMenuContent>
|
|
66
102
|
</DropdownMenu>
|
|
67
103
|
</SidebarMenuItem>
|
|
@@ -75,7 +75,9 @@ interface TableToolbarProps {
|
|
|
75
75
|
// and resolved with `t()` at render time so they follow the active language.
|
|
76
76
|
const operatorsByType: Record<string, { value: string; labelKey: string }[]> = {
|
|
77
77
|
string: [
|
|
78
|
-
|
|
78
|
+
// `i_cont` (case-insensitive) rather than `cont` — an admin typing
|
|
79
|
+
// "2 years" expects to match "2 Years".
|
|
80
|
+
{ value: 'i_cont', labelKey: 'admin.components.table_toolbar.operators.contains' },
|
|
79
81
|
{ value: 'eq', labelKey: 'admin.components.table_toolbar.operators.equals' },
|
|
80
82
|
{ value: 'not_eq', labelKey: 'admin.components.table_toolbar.operators.does_not_equal' },
|
|
81
83
|
{ value: 'start', labelKey: 'admin.components.table_toolbar.operators.starts_with' },
|
|
@@ -128,6 +130,19 @@ function getOperators(type: string) {
|
|
|
128
130
|
return operatorsByType[type] || operatorsByType.string
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
// Operators no longer offered in the picker but still resolvable for display,
|
|
134
|
+
// so filters saved in older URLs keep a readable chip label.
|
|
135
|
+
const legacyOperatorLabelKeys: Record<string, string> = {
|
|
136
|
+
cont: 'admin.components.table_toolbar.operators.contains',
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getOperatorLabelKey(type: string, operator: string) {
|
|
140
|
+
return (
|
|
141
|
+
getOperators(type).find((o) => o.value === operator)?.labelKey ??
|
|
142
|
+
legacyOperatorLabelKeys[operator]
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
131
146
|
const noValueOperators = ['present', 'blank']
|
|
132
147
|
|
|
133
148
|
// ============================================================================
|
|
@@ -283,8 +298,7 @@ function FilterChip({
|
|
|
283
298
|
onRemove: () => void
|
|
284
299
|
}) {
|
|
285
300
|
const { t } = useTranslation()
|
|
286
|
-
const
|
|
287
|
-
const opLabelKey = ops.find((o) => o.value === filter.operator)?.labelKey
|
|
301
|
+
const opLabelKey = getOperatorLabelKey(col?.filterType ?? 'string', filter.operator)
|
|
288
302
|
const opLabel = opLabelKey ? t(opLabelKey) : filter.operator
|
|
289
303
|
const showValue = !noValueOperators.includes(filter.operator)
|
|
290
304
|
|
|
@@ -28,6 +28,7 @@ import { useCommandPalette } from '../hooks/use-command-palette'
|
|
|
28
28
|
import { useSwitchAdminLocale } from '../hooks/use-switch-admin-locale'
|
|
29
29
|
import { getInitials } from '../lib/formatters'
|
|
30
30
|
import { i18n } from '../lib/i18n'
|
|
31
|
+
import { storefrontHref } from '../lib/storefront'
|
|
31
32
|
import { useStore } from '../providers/store-provider'
|
|
32
33
|
|
|
33
34
|
const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform ?? '')
|
|
@@ -96,10 +97,8 @@ function SearchTrigger() {
|
|
|
96
97
|
function ViewStoreLink() {
|
|
97
98
|
const { t } = useTranslation()
|
|
98
99
|
const { store } = useStore()
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// Best-effort: prefix with https if the URL is just a hostname.
|
|
102
|
-
const href = /^https?:\/\//.test(store.url) ? store.url : `https://${store.url}`
|
|
100
|
+
const href = storefrontHref(store)
|
|
101
|
+
if (!href) return null
|
|
103
102
|
|
|
104
103
|
return (
|
|
105
104
|
<Button asChild variant="ghost" size="sm">
|
package/src/hooks/use-import.ts
CHANGED
|
@@ -47,6 +47,28 @@ export function useDownloadImportTemplate() {
|
|
|
47
47
|
})
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Downloads the populated example CSV for an import type.
|
|
52
|
+
*
|
|
53
|
+
* Goes through the API rather than linking the file directly: the URL is pinned
|
|
54
|
+
* to the installed Spree version so a released install never links to a newer,
|
|
55
|
+
* incompatible schema — and that version is deliberately not exposed over the
|
|
56
|
+
* API, since it would fingerprint the deployment. Returns 404 for a type that
|
|
57
|
+
* ships no example file.
|
|
58
|
+
*/
|
|
59
|
+
export function useDownloadImportExample() {
|
|
60
|
+
const { token } = useAuth()
|
|
61
|
+
|
|
62
|
+
return useMutation({
|
|
63
|
+
mutationFn: (type: ImportType) =>
|
|
64
|
+
downloadFromApi(
|
|
65
|
+
token,
|
|
66
|
+
`/api/v3/admin/imports/example?type=${encodeURIComponent(type)}`,
|
|
67
|
+
`${type}.csv`,
|
|
68
|
+
),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
50
72
|
/** Downloads the originally uploaded CSV of an import — the audit trail. */
|
|
51
73
|
export function useDownloadImportOriginal() {
|
|
52
74
|
const { token } = useAuth()
|
package/src/lib/permissions.ts
CHANGED
|
@@ -16,6 +16,7 @@ export const Subject = {
|
|
|
16
16
|
AllowedOrigin: 'Spree::AllowedOrigin',
|
|
17
17
|
Store: 'Spree::Store',
|
|
18
18
|
Channel: 'Spree::Channel',
|
|
19
|
+
OrderRoutingRule: 'Spree::OrderRoutingRule',
|
|
19
20
|
// Categories are Spree::Category < Spree::Taxon; abilities are published under
|
|
20
21
|
// the Spree::Taxon subject, so the value stays 'Spree::Taxon' while the key
|
|
21
22
|
// reads as the user-facing resource name.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ImportType } from '@spree/admin-sdk'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Import types that ship a populated example CSV in Spree's
|
|
5
|
+
* `core/db/sample_data`. Keyed by the API's type shorthand (`"products"`),
|
|
6
|
+
* which is also the CSV's filename stem.
|
|
7
|
+
*
|
|
8
|
+
* Only used to decide whether to render the download link — the file itself is
|
|
9
|
+
* served by `GET /api/v3/admin/imports/example`, which pins the URL to the
|
|
10
|
+
* installed Spree version. A type missing from this list simply hides the link;
|
|
11
|
+
* the endpoint is the authority and 404s for anything it has no example for.
|
|
12
|
+
*/
|
|
13
|
+
const TYPES_WITH_SAMPLE_CSV = new Set(['products', 'customers', 'product_translations'])
|
|
14
|
+
|
|
15
|
+
/** Whether an import type has an example CSV to offer. */
|
|
16
|
+
export function hasSampleCsv(type: ImportType): boolean {
|
|
17
|
+
return TYPES_WITH_SAMPLE_CSV.has(type)
|
|
18
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Store } from '@spree/admin-sdk'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the storefront URL to link to from admin "View store" affordances,
|
|
5
|
+
* or `null` when no storefront URL is configured.
|
|
6
|
+
*
|
|
7
|
+
* The serialized `store.url` always resolves — the backend falls back to the
|
|
8
|
+
* store's formatted URL — so gate on the explicit `preferred_storefront_url`
|
|
9
|
+
* preference: only return a link once a real storefront has been set.
|
|
10
|
+
*
|
|
11
|
+
* @param store the current store, or null/undefined while it loads
|
|
12
|
+
* @returns an absolute URL, or null when the store has no configured storefront
|
|
13
|
+
*/
|
|
14
|
+
export function storefrontHref(store: Store | null | undefined): string | null {
|
|
15
|
+
if (!store?.preferred_storefront_url) return null
|
|
16
|
+
|
|
17
|
+
// Best-effort: prefix with https if the URL is just a hostname.
|
|
18
|
+
return /^https?:\/\//.test(store.url) ? store.url : `https://${store.url}`
|
|
19
|
+
}
|
|
@@ -16,6 +16,12 @@ interface ColumnDefBase<T = any> {
|
|
|
16
16
|
ransackAttribute?: string
|
|
17
17
|
/** Custom cell renderer. If omitted, renders `row[key]` as text. */
|
|
18
18
|
render?: (row: T) => ReactNode
|
|
19
|
+
/**
|
|
20
|
+
* `expand` value the list request needs while this column is visible
|
|
21
|
+
* (e.g. `'custom_fields'`). ResourceTable unions the values across
|
|
22
|
+
* visible columns into the `expand` query param.
|
|
23
|
+
*/
|
|
24
|
+
expand?: string
|
|
19
25
|
/** Cell className */
|
|
20
26
|
className?: string
|
|
21
27
|
/** Whether this column is displayable in the table. Set to false for filter-only columns. */
|
package/src/locales/ar.json
CHANGED
|
@@ -76,7 +76,8 @@
|
|
|
76
76
|
"remove_value": "إزالة القيمة",
|
|
77
77
|
"breadcrumb_more": "المزيد",
|
|
78
78
|
"sidebar": "الشريط الجانبي",
|
|
79
|
-
"sidebar_description": "يعرض الشريط الجانبي للجوال."
|
|
79
|
+
"sidebar_description": "يعرض الشريط الجانبي للجوال.",
|
|
80
|
+
"store_switcher": "تبديل المتجر"
|
|
80
81
|
},
|
|
81
82
|
"common": {
|
|
82
83
|
"loading": "جارٍ التحميل…",
|
|
@@ -406,7 +407,8 @@
|
|
|
406
407
|
"empty": "لا توجد مستويات بعد. ينطبق السعر الأساسي حتى تضيف مستوى.",
|
|
407
408
|
"add_tier": "إضافة مستوى",
|
|
408
409
|
"remove_tier": "إزالة المستوى"
|
|
409
|
-
}
|
|
410
|
+
},
|
|
411
|
+
"unlimited": "غير محدود"
|
|
410
412
|
},
|
|
411
413
|
"confirm_dialog": {
|
|
412
414
|
"title": "هل أنت متأكد؟"
|
package/src/locales/de.json
CHANGED
|
@@ -76,7 +76,8 @@
|
|
|
76
76
|
"remove_value": "Wert entfernen",
|
|
77
77
|
"breadcrumb_more": "Mehr",
|
|
78
78
|
"sidebar": "Seitenleiste",
|
|
79
|
-
"sidebar_description": "Zeigt die mobile Seitenleiste an."
|
|
79
|
+
"sidebar_description": "Zeigt die mobile Seitenleiste an.",
|
|
80
|
+
"store_switcher": "Store wechseln"
|
|
80
81
|
},
|
|
81
82
|
"common": {
|
|
82
83
|
"loading": "Wird geladen…",
|
|
@@ -406,7 +407,8 @@
|
|
|
406
407
|
"empty": "Noch keine Stufen. Der Basissatz gilt, bis Sie eine Stufe hinzufügen.",
|
|
407
408
|
"add_tier": "Stufe hinzufügen",
|
|
408
409
|
"remove_tier": "Stufe entfernen"
|
|
409
|
-
}
|
|
410
|
+
},
|
|
411
|
+
"unlimited": "Unbegrenzt"
|
|
410
412
|
},
|
|
411
413
|
"confirm_dialog": {
|
|
412
414
|
"title": "Sind Sie sicher?"
|
package/src/locales/en.json
CHANGED
|
@@ -76,7 +76,8 @@
|
|
|
76
76
|
"remove_value": "Remove value",
|
|
77
77
|
"breadcrumb_more": "More",
|
|
78
78
|
"sidebar": "Sidebar",
|
|
79
|
-
"sidebar_description": "Displays the mobile sidebar."
|
|
79
|
+
"sidebar_description": "Displays the mobile sidebar.",
|
|
80
|
+
"store_switcher": "Switch store"
|
|
80
81
|
},
|
|
81
82
|
"common": {
|
|
82
83
|
"loading": "Loading…",
|
|
@@ -406,7 +407,8 @@
|
|
|
406
407
|
"empty": "No tiers yet. The base rate applies until you add a tier.",
|
|
407
408
|
"add_tier": "Add tier",
|
|
408
409
|
"remove_tier": "Remove tier"
|
|
409
|
-
}
|
|
410
|
+
},
|
|
411
|
+
"unlimited": "Unlimited"
|
|
410
412
|
},
|
|
411
413
|
"confirm_dialog": {
|
|
412
414
|
"title": "Are you sure?"
|
|
@@ -489,7 +491,9 @@
|
|
|
489
491
|
"tab": "Tab"
|
|
490
492
|
},
|
|
491
493
|
"download_template": "Download CSV template",
|
|
494
|
+
"download_example": "Download an example CSV",
|
|
492
495
|
"template_failed": "Template download failed: {{message}}",
|
|
496
|
+
"example_failed": "Example download failed: {{message}}",
|
|
493
497
|
"submit": "Continue",
|
|
494
498
|
"failed": "Import failed: {{message}}"
|
|
495
499
|
},
|
package/src/locales/fr.json
CHANGED
|
@@ -76,7 +76,8 @@
|
|
|
76
76
|
"remove_value": "Retirer la valeur",
|
|
77
77
|
"breadcrumb_more": "Plus",
|
|
78
78
|
"sidebar": "Barre latérale",
|
|
79
|
-
"sidebar_description": "Affiche la barre latérale mobile."
|
|
79
|
+
"sidebar_description": "Affiche la barre latérale mobile.",
|
|
80
|
+
"store_switcher": "Changer de boutique"
|
|
80
81
|
},
|
|
81
82
|
"common": {
|
|
82
83
|
"loading": "Chargement…",
|
|
@@ -406,7 +407,8 @@
|
|
|
406
407
|
"empty": "Aucun palier pour le moment. Le taux de base s’applique jusqu’à l’ajout d’un palier.",
|
|
407
408
|
"add_tier": "Ajouter un palier",
|
|
408
409
|
"remove_tier": "Supprimer le palier"
|
|
409
|
-
}
|
|
410
|
+
},
|
|
411
|
+
"unlimited": "Illimité"
|
|
410
412
|
},
|
|
411
413
|
"confirm_dialog": {
|
|
412
414
|
"title": "Êtes-vous sûr ?"
|
package/src/locales/pl.json
CHANGED
|
@@ -76,7 +76,8 @@
|
|
|
76
76
|
"remove_value": "Usuń wartość",
|
|
77
77
|
"breadcrumb_more": "Więcej",
|
|
78
78
|
"sidebar": "Panel boczny",
|
|
79
|
-
"sidebar_description": "Wyświetla mobilny panel boczny."
|
|
79
|
+
"sidebar_description": "Wyświetla mobilny panel boczny.",
|
|
80
|
+
"store_switcher": "Przełącz sklep"
|
|
80
81
|
},
|
|
81
82
|
"common": {
|
|
82
83
|
"loading": "Ładowanie…",
|
|
@@ -406,7 +407,8 @@
|
|
|
406
407
|
"empty": "Brak progów. Do momentu dodania progu obowiązuje stawka podstawowa.",
|
|
407
408
|
"add_tier": "Dodaj próg",
|
|
408
409
|
"remove_tier": "Usuń próg"
|
|
409
|
-
}
|
|
410
|
+
},
|
|
411
|
+
"unlimited": "Bez limitu"
|
|
410
412
|
},
|
|
411
413
|
"confirm_dialog": {
|
|
412
414
|
"title": "Czy na pewno?"
|
package/src/locales/zh-CN.json
CHANGED
|
@@ -76,7 +76,8 @@
|
|
|
76
76
|
"remove_value": "移除值",
|
|
77
77
|
"breadcrumb_more": "更多",
|
|
78
78
|
"sidebar": "侧边栏",
|
|
79
|
-
"sidebar_description": "显示移动端侧边栏。"
|
|
79
|
+
"sidebar_description": "显示移动端侧边栏。",
|
|
80
|
+
"store_switcher": "切换店铺"
|
|
80
81
|
},
|
|
81
82
|
"common": {
|
|
82
83
|
"loading": "加载中…",
|
|
@@ -406,7 +407,8 @@
|
|
|
406
407
|
"empty": "暂无层级。在添加层级之前,将应用基础费率。",
|
|
407
408
|
"add_tier": "添加层级",
|
|
408
409
|
"remove_tier": "移除层级"
|
|
409
|
-
}
|
|
410
|
+
},
|
|
411
|
+
"unlimited": "无限制"
|
|
410
412
|
},
|
|
411
413
|
"confirm_dialog": {
|
|
412
414
|
"title": "确定吗?"
|