@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spree/dashboard-core",
3
3
  "license": "MIT",
4
- "version": "0.12.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.7.0",
83
- "@spree/dashboard-ui": "^0.12.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 { useCreateImport, useDownloadImportTemplate } from '../hooks/use-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
- <Button
166
- type="button"
167
- variant="ghost"
168
- size="sm"
169
- className="self-start"
170
- onClick={handleTemplateDownload}
171
- disabled={downloadTemplate.isPending}
172
- >
173
- <DownloadIcon className="size-4" />
174
- {t('admin.components.import_button.download_template')}
175
- </Button>
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
- const displayableColumns = getDisplayableColumns(table)
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 = displayableColumns.filter((c) => visibleColumnKeys.includes(c.key))
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[], table.columns))
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: table.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={table.columns}
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 { ChevronsUpDownIcon, ExternalLinkIcon } from 'lucide-react'
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
- <Avatar>
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
- <DropdownMenuItem>
62
- <ExternalLinkIcon className="size-4" />
63
- {t('admin.account.view_store')}
64
- </DropdownMenuItem>
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
- { value: 'cont', labelKey: 'admin.components.table_toolbar.operators.contains' },
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 ops = getOperators(col?.filterType ?? 'string')
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
- if (!store?.url) return null
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">
@@ -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()
@@ -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. */
@@ -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": "هل أنت متأكد؟"
@@ -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?"
@@ -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
  },
@@ -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 ?"
@@ -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?"
@@ -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": "确定吗?"