@spree/dashboard-core 1.0.0-beta.2 → 1.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -5
- package/src/api-client.ts +43 -0
- package/src/components/address-form-dialog.tsx +5 -2
- package/src/components/app-sidebar.tsx +16 -2
- package/src/components/currency-select.tsx +1 -15
- package/src/components/nav-main.tsx +50 -37
- package/src/components/sheet-top-bar.tsx +1 -1
- package/src/components/table-toolbar.tsx +9 -63
- package/src/components/top-bar-breadcrumbs.tsx +9 -2
- package/src/hooks/use-switch-admin-locale.ts +11 -2
- package/src/lib/currencies.ts +15 -0
- package/src/lib/form-mappers.ts +40 -0
- package/src/lib/fulfillment-items.test.ts +29 -0
- package/src/lib/fulfillment-items.ts +35 -17
- package/src/lib/i18n.ts +79 -4
- package/src/lib/permissions.ts +1 -1
- package/src/lib/settings-nav-registry.ts +27 -0
- package/src/lib/slot-registry.ts +18 -0
- package/src/lib/table-registry.ts +2 -2
- package/src/locales/ar.json +32 -2
- package/src/locales/de.json +32 -2
- package/src/locales/en.json +32 -2
- package/src/locales/es.json +2030 -0
- package/src/locales/fr.json +32 -2
- package/src/locales/pl.json +32 -2
- package/src/locales/zh-CN.json +32 -2
- package/src/pages/package-types.tsx +19 -17
- package/src/products/normalize-money.test.ts +9 -0
- package/src/products/normalize-money.ts +12 -0
- package/src/products/product-form-cards.tsx +40 -16
- package/src/products/product-form-mapping.test.ts +72 -0
- package/src/products/product-form-mapping.ts +3 -1
- package/src/products/product-types.ts +1 -0
- package/src/products/variants-section.tsx +5 -2
- package/src/providers/auth-provider.tsx +3 -3
- package/src/schemas/package-type.ts +7 -4
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spree/dashboard-core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "1.0.0-beta.
|
|
4
|
+
"version": "1.0.0-beta.4",
|
|
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",
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
"types": "./src/index.ts",
|
|
26
26
|
"exports": {
|
|
27
27
|
".": "./src/index.ts",
|
|
28
|
+
"./client": "./src/client.ts",
|
|
29
|
+
"./api-client": "./src/api-client.ts",
|
|
28
30
|
"./plugin": "./src/plugin.ts",
|
|
29
31
|
"./vite": {
|
|
30
32
|
"types": "./src/vite/index.ts",
|
|
@@ -65,22 +67,22 @@
|
|
|
65
67
|
"@dnd-kit/sortable": "^10.0.0",
|
|
66
68
|
"@dnd-kit/utilities": "^3.2.2",
|
|
67
69
|
"@tailwindcss/vite": "^4.2.4",
|
|
70
|
+
"@types/spark-md5": "^3.0.5",
|
|
68
71
|
"date-fns": "^4.1.0",
|
|
69
72
|
"date-fns-tz": "^3.2.0",
|
|
70
|
-
"lucide-react": "^1.
|
|
73
|
+
"lucide-react": "^1.47.0",
|
|
71
74
|
"spark-md5": "^3.0.2",
|
|
72
75
|
"zod": "^4.4.3"
|
|
73
76
|
},
|
|
74
77
|
"devDependencies": {
|
|
75
78
|
"@types/react": "^19.2.14",
|
|
76
79
|
"@types/react-dom": "^19.2.3",
|
|
77
|
-
"@types/spark-md5": "^3.0.5",
|
|
78
80
|
"tsup": "^8.5.1",
|
|
79
81
|
"typescript": "~6.0.3",
|
|
80
82
|
"vite": "^8.0.16",
|
|
81
83
|
"vitest": "^4.1.5",
|
|
82
|
-
"@spree/admin-sdk": "^1.0.0-beta.
|
|
83
|
-
"@spree/dashboard-ui": "^1.0.0-beta.
|
|
84
|
+
"@spree/admin-sdk": "^1.0.0-beta.3",
|
|
85
|
+
"@spree/dashboard-ui": "^1.0.0-beta.4"
|
|
84
86
|
},
|
|
85
87
|
"scripts": {
|
|
86
88
|
"lint": "biome check src",
|
package/src/api-client.ts
CHANGED
|
@@ -16,6 +16,34 @@
|
|
|
16
16
|
|
|
17
17
|
import type { PermissionRule } from '@spree/admin-sdk'
|
|
18
18
|
|
|
19
|
+
/** What a panel may change on the signed-in person's own account. */
|
|
20
|
+
export interface PanelAccountParams {
|
|
21
|
+
first_name?: string
|
|
22
|
+
last_name?: string
|
|
23
|
+
/** The panel's UI language, as a bundle code the panel ships (e.g. `de`). */
|
|
24
|
+
selected_locale?: string
|
|
25
|
+
/**
|
|
26
|
+
* ActiveStorage signed id to set the photo, or `null` to remove it. Omit to
|
|
27
|
+
* leave the current one alone — the only field here where `null` means
|
|
28
|
+
* something the API acts on.
|
|
29
|
+
*/
|
|
30
|
+
avatar?: string | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The account fields both panels' `/me` answer with. Deliberately the subset
|
|
35
|
+
* they share — anything one API alone returns stays on its own client.
|
|
36
|
+
*/
|
|
37
|
+
export interface PanelAccountUser {
|
|
38
|
+
id: string
|
|
39
|
+
email: string
|
|
40
|
+
first_name?: string | null
|
|
41
|
+
last_name?: string | null
|
|
42
|
+
full_name?: string | null
|
|
43
|
+
selected_locale?: string | null
|
|
44
|
+
avatar_url?: string | null
|
|
45
|
+
}
|
|
46
|
+
|
|
19
47
|
/** What the permission provider needs, whichever panel asked. */
|
|
20
48
|
export interface PanelPermissions {
|
|
21
49
|
/**
|
|
@@ -90,6 +118,21 @@ export interface PanelApiClient {
|
|
|
90
118
|
direct_upload: { url: string; headers: Record<string, string> }
|
|
91
119
|
signed_id: string
|
|
92
120
|
}>
|
|
121
|
+
/**
|
|
122
|
+
* Writes the signed-in person's own account — their name, photo and the
|
|
123
|
+
* panel's language.
|
|
124
|
+
*
|
|
125
|
+
* Registered rather than imported for the same reason as everything else
|
|
126
|
+
* here: the account menu's language switcher is one shared component, and
|
|
127
|
+
* reaching for `adminClient` inside it would send a seller's language
|
|
128
|
+
* change to an API they hold no credential for.
|
|
129
|
+
*
|
|
130
|
+
* Required, unlike most of this contract: the account menu lives in the
|
|
131
|
+
* shared sidebar, so every panel mounting the shell already offers the
|
|
132
|
+
* language switcher that calls this. An optional marker would describe a
|
|
133
|
+
* host that does not exist and push a runtime guard onto every caller.
|
|
134
|
+
*/
|
|
135
|
+
updateAccount(params: PanelAccountParams): Promise<{ user: PanelAccountUser }>
|
|
93
136
|
stockLocations?: {
|
|
94
137
|
list(params?: Record<string, unknown>): Promise<{ data: PanelStockLocation[]; meta?: unknown }>
|
|
95
138
|
get(id: string): Promise<PanelStockLocation>
|
|
@@ -226,8 +226,11 @@ export function AddressFormDialog({
|
|
|
226
226
|
}, [address?.country_code, storeDefaultCountryCode, form])
|
|
227
227
|
|
|
228
228
|
const countryCode = form.watch('country_code')
|
|
229
|
-
const { states
|
|
230
|
-
|
|
229
|
+
const { states } = useCountryStates(countryCode)
|
|
230
|
+
// Show the dropdown whenever the country enumerates subdivisions, not only
|
|
231
|
+
// when it strictly requires one — so a country like Pakistan still lets the
|
|
232
|
+
// user pick a province instead of typing a name that may not match.
|
|
233
|
+
const useStateCombobox = states.length > 0
|
|
231
234
|
|
|
232
235
|
async function onSubmit(values: AddressFormValues) {
|
|
233
236
|
try {
|
|
@@ -19,7 +19,7 @@ import { type NavEntry, resolveNavLabel, useNavEntries } from '../lib/nav-regist
|
|
|
19
19
|
import type { ActionName, SubjectName } from '../lib/permissions'
|
|
20
20
|
import { type Permissions, usePermissions } from '../providers/permission-provider'
|
|
21
21
|
import { useOptionalStore } from '../providers/store-provider'
|
|
22
|
-
import { type NavItem, NavMain } from './nav-main'
|
|
22
|
+
import { NavBottom, type NavItem, NavMain } from './nav-main'
|
|
23
23
|
import { SidebarSearch } from './sidebar-search'
|
|
24
24
|
import { SidebarUser } from './sidebar-user'
|
|
25
25
|
import { StoreSwitcher } from './store-switcher'
|
|
@@ -146,6 +146,7 @@ export function AppSidebar({
|
|
|
146
146
|
const { i18n } = useTranslation()
|
|
147
147
|
const { storeId } = useParams({ strict: false }) as { storeId?: string }
|
|
148
148
|
const { navItems, bottomItems, isLoading } = useNavItems(tenantId ?? storeId ?? 'default')
|
|
149
|
+
const { isMobile } = useSidebar()
|
|
149
150
|
|
|
150
151
|
return (
|
|
151
152
|
<Sidebar collapsible="icon" variant="inset" side={primarySidebarSide(i18n.language)} {...props}>
|
|
@@ -173,8 +174,21 @@ export function AppSidebar({
|
|
|
173
174
|
Keyed on `isLoading` alone, not on an empty list: entries without a
|
|
174
175
|
`subject` (Home, Getting Started) skip the permission filter, so the
|
|
175
176
|
list is never actually empty and a length check would never fire. */}
|
|
176
|
-
{isLoading ?
|
|
177
|
+
{isLoading ? (
|
|
178
|
+
<NavSkeleton />
|
|
179
|
+
) : (
|
|
180
|
+
<>
|
|
181
|
+
<NavMain items={navItems} />
|
|
182
|
+
{/* Pinned to the foot of a full-height drawer, Settings would sit
|
|
183
|
+
alone below a screen of empty space, so on a phone it follows
|
|
184
|
+
the list. */}
|
|
185
|
+
{isMobile && <NavBottom items={bottomItems} className="mb-2" />}
|
|
186
|
+
</>
|
|
187
|
+
)}
|
|
177
188
|
</SidebarContent>
|
|
189
|
+
{/* Outside the scroll area on desktop, so Settings stays in reach and the
|
|
190
|
+
list fades out above it rather than scrolling it away. */}
|
|
191
|
+
{!isLoading && !isMobile && <NavBottom items={bottomItems} className="py-2" />}
|
|
178
192
|
<SidebarFooter className="hidden md:flex">
|
|
179
193
|
<SidebarUser uiLocales={uiLocales} onEditProfile={onEditProfile} />
|
|
180
194
|
</SidebarFooter>
|
|
@@ -13,21 +13,7 @@ import { useTranslation } from 'react-i18next'
|
|
|
13
13
|
import { useDisplayName } from '../hooks/use-display-name'
|
|
14
14
|
import { useOptionalStore } from '../providers/store-provider'
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
* Every ISO 4217 currency code the runtime knows about — the active-currency
|
|
18
|
-
* counterpart to the Rails admin's `Money::Currency.table` list. Used for
|
|
19
|
-
* contexts where the merchant can pick *any* currency (a market's currency)
|
|
20
|
-
* rather than one the store already supports. Degrades to an empty list on
|
|
21
|
-
* runtimes without `Intl.supportedValuesOf`, so callers fall back to
|
|
22
|
-
* `supported_currencies`.
|
|
23
|
-
*/
|
|
24
|
-
export const ALL_CURRENCY_CODES: string[] = (() => {
|
|
25
|
-
try {
|
|
26
|
-
return Intl.supportedValuesOf('currency')
|
|
27
|
-
} catch {
|
|
28
|
-
return []
|
|
29
|
-
}
|
|
30
|
-
})()
|
|
16
|
+
export { ALL_CURRENCY_CODES } from '../lib/currencies'
|
|
31
17
|
|
|
32
18
|
interface CurrencySelectProps {
|
|
33
19
|
/** Classes for the trigger — used to match a neighbouring control's height. */
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
cn,
|
|
3
2
|
DropdownMenu,
|
|
4
3
|
DropdownMenuContent,
|
|
5
4
|
DropdownMenuItem,
|
|
@@ -279,20 +278,25 @@ function NavItemContent({
|
|
|
279
278
|
)
|
|
280
279
|
}
|
|
281
280
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
// watching the path: this tree renders *inside* the Sheet, so it remounts on
|
|
289
|
-
// every open and an effect could not tell "just opened" from "just navigated".
|
|
290
|
-
const handleLinkTap = useCallback(
|
|
281
|
+
// Close the drawer as the link is tapped. Done here rather than in an effect
|
|
282
|
+
// watching the path: this tree renders *inside* the Sheet, so it remounts on
|
|
283
|
+
// every open and an effect could not tell "just opened" from "just navigated".
|
|
284
|
+
function useCloseDrawerOnLinkTap() {
|
|
285
|
+
const { isMobile, setOpenMobile } = useSidebar()
|
|
286
|
+
return useCallback(
|
|
291
287
|
(event: React.MouseEvent) => {
|
|
292
288
|
if (isMobile && (event.target as HTMLElement).closest('a')) setOpenMobile(false)
|
|
293
289
|
},
|
|
294
290
|
[isMobile, setOpenMobile],
|
|
295
291
|
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function NavMain({ items }: { items: NavItem[] }) {
|
|
295
|
+
const routerState = useRouterState()
|
|
296
|
+
const currentPath = routerState.location.pathname
|
|
297
|
+
const { state, isMobile } = useSidebar()
|
|
298
|
+
const isCollapsed = state === 'collapsed'
|
|
299
|
+
const handleLinkTap = useCloseDrawerOnLinkTap()
|
|
296
300
|
// One controller for the whole rail so only a single hover menu is ever open.
|
|
297
301
|
const hoverMenu = useHoverMenuController()
|
|
298
302
|
|
|
@@ -325,35 +329,44 @@ export function NavMain({ items, bottomItems }: { items: NavItem[]; bottomItems?
|
|
|
325
329
|
))}
|
|
326
330
|
</SidebarMenu>
|
|
327
331
|
</SidebarGroup>
|
|
332
|
+
</div>
|
|
333
|
+
)
|
|
334
|
+
}
|
|
328
335
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
<SidebarMenu>
|
|
335
|
-
{bottomItems.map((item) => {
|
|
336
|
-
const isActive =
|
|
337
|
-
currentPath === item.url ||
|
|
338
|
-
(item.url !== '/' && isPathWithin(currentPath, item.url))
|
|
336
|
+
/** The entries kept apart from the main list — Settings, and anything registered beside it. */
|
|
337
|
+
export function NavBottom({ items, className }: { items: NavItem[]; className?: string }) {
|
|
338
|
+
const currentPath = useRouterState().location.pathname
|
|
339
|
+
const isCollapsed = useSidebar().state === 'collapsed'
|
|
340
|
+
const handleLinkTap = useCloseDrawerOnLinkTap()
|
|
339
341
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
342
|
+
if (items.length === 0) return null
|
|
343
|
+
|
|
344
|
+
return (
|
|
345
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: delegated link taps only
|
|
346
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: links keep their own keyboard behaviour
|
|
347
|
+
<div onClick={handleLinkTap} className="contents">
|
|
348
|
+
<SidebarGroup className={className}>
|
|
349
|
+
<SidebarMenu>
|
|
350
|
+
{items.map((item) => {
|
|
351
|
+
const isActive =
|
|
352
|
+
currentPath === item.url || (item.url !== '/' && isPathWithin(currentPath, item.url))
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<SidebarMenuItem key={item.title}>
|
|
356
|
+
<SidebarMenuButton tooltip={item.title} asChild isActive={isActive}>
|
|
357
|
+
{/* `aria-label` for the same reason as the main nav: collapsed
|
|
358
|
+
mode hides the label, and Settings lives here — the one
|
|
359
|
+
place the rail is collapsed by default. */}
|
|
360
|
+
<Link to={item.url} aria-label={isCollapsed ? item.title : undefined}>
|
|
361
|
+
<NavIcon icon={item.icon} isActive={isActive} />
|
|
362
|
+
<span>{item.title}</span>
|
|
363
|
+
</Link>
|
|
364
|
+
</SidebarMenuButton>
|
|
365
|
+
</SidebarMenuItem>
|
|
366
|
+
)
|
|
367
|
+
})}
|
|
368
|
+
</SidebarMenu>
|
|
369
|
+
</SidebarGroup>
|
|
357
370
|
</div>
|
|
358
371
|
)
|
|
359
372
|
}
|
|
@@ -34,7 +34,7 @@ export function SheetTopBar({
|
|
|
34
34
|
if (!collapsed) return null
|
|
35
35
|
|
|
36
36
|
return (
|
|
37
|
-
<div className="hidden min-h-8 items-center gap-2 px-4 pt-3 md:flex
|
|
37
|
+
<div className="hidden min-h-8 items-center gap-2 px-4 pt-3 md:flex">
|
|
38
38
|
{!inSettings && (
|
|
39
39
|
<SidebarTrigger className="-ms-1 shrink-0 text-muted-foreground hover:bg-accent hover:text-foreground" />
|
|
40
40
|
)}
|
|
@@ -306,8 +306,6 @@ export function TableToolbar({
|
|
|
306
306
|
<PopoverContent align="start" className="w-[min(480px,calc(100vw-1rem))] p-0">
|
|
307
307
|
<FilterPanel
|
|
308
308
|
columns={panelColumns}
|
|
309
|
-
allColumns={filterableColumns}
|
|
310
|
-
quickFilterKeys={quickFilterKeys}
|
|
311
309
|
filters={filters}
|
|
312
310
|
onApply={(f) => {
|
|
313
311
|
onFiltersChange(f)
|
|
@@ -1233,22 +1231,14 @@ function InlineValueList({
|
|
|
1233
1231
|
*/
|
|
1234
1232
|
function FilterPanel({
|
|
1235
1233
|
columns,
|
|
1236
|
-
allColumns,
|
|
1237
|
-
quickFilterKeys,
|
|
1238
1234
|
filters,
|
|
1239
1235
|
onApply,
|
|
1240
1236
|
onChange,
|
|
1241
1237
|
}: {
|
|
1242
|
-
/**
|
|
1238
|
+
/** The fields with no quick control of their own. A field that has one is
|
|
1239
|
+
* filtered there alone — its checkbox list already says "is" and "is not",
|
|
1240
|
+
* so offering it here too only listed the same field twice. */
|
|
1243
1241
|
columns: ColumnDef[]
|
|
1244
|
-
/** Every filterable field. The ones absent from `columns` are listed after
|
|
1245
|
-
* them, for the operators their quick control cannot express. */
|
|
1246
|
-
allColumns: ColumnDef[]
|
|
1247
|
-
/** Which fields have a quick control on the row. Such a field goes to the
|
|
1248
|
-
* operator picker rather than the one-click value list: the list writes
|
|
1249
|
-
* `eq`, and only `in` is the operator a control displays, so a one-click
|
|
1250
|
-
* `eq` would chip while its control still read "All". */
|
|
1251
|
-
quickFilterKeys: Set<string>
|
|
1252
1242
|
filters: FilterRule[]
|
|
1253
1243
|
/** Commit and close — for the filters that are finished in one action. */
|
|
1254
1244
|
onApply: (filters: FilterRule[]) => void
|
|
@@ -1269,9 +1259,7 @@ function FilterPanel({
|
|
|
1269
1259
|
// strand a half-entered range.
|
|
1270
1260
|
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null)
|
|
1271
1261
|
|
|
1272
|
-
|
|
1273
|
-
// select can land on a quick-filtered field the list deliberately omits.
|
|
1274
|
-
const column = allColumns.find((c) => c.key === field)
|
|
1262
|
+
const column = columns.find((c) => c.key === field)
|
|
1275
1263
|
const type = column?.filterType ?? 'string'
|
|
1276
1264
|
const operators = useMemo(
|
|
1277
1265
|
() => getOperators(type).map((op) => ({ value: op.value, label: t(op.labelKey) })),
|
|
@@ -1284,18 +1272,12 @@ function FilterPanel({
|
|
|
1284
1272
|
// Types whose values are chosen from a list rather than typed. `multi` ones
|
|
1285
1273
|
// accumulate a set and need an explicit Apply; the single ones commit on the
|
|
1286
1274
|
// click that picks them.
|
|
1287
|
-
|
|
1288
|
-
// here for the operators that control cannot express, so it skips the
|
|
1289
|
-
// shorthands — the value list and the date presets — and goes to the
|
|
1290
|
-
// operator picker. Those shorthands write exactly what the control already
|
|
1291
|
-
// writes, which is the one thing the panel does not need to offer twice.
|
|
1292
|
-
const quickFiltered = Boolean(field) && quickFilterKeys.has(field as string)
|
|
1293
|
-
const multi = !quickFiltered && (type === 'tags' || type === 'resource' || type === 'currency')
|
|
1275
|
+
const multi = type === 'tags' || type === 'resource' || type === 'currency'
|
|
1294
1276
|
// Dates are their own shape: a list of relative presets, with the calendar
|
|
1295
1277
|
// behind "custom". Same presets the quick filter offers, so a range means
|
|
1296
1278
|
// the same thing wherever it was set.
|
|
1297
|
-
const dated =
|
|
1298
|
-
const listed =
|
|
1279
|
+
const dated = type === 'date'
|
|
1280
|
+
const listed = type === 'enum' || type === 'boolean' || multi
|
|
1299
1281
|
// The header search filters the list below it, so it earns its place only
|
|
1300
1282
|
// when there is a list: the fields, or a list-shaped type's values.
|
|
1301
1283
|
const searchable = (!field || listed) && !dated
|
|
@@ -1319,18 +1301,8 @@ function FilterPanel({
|
|
|
1319
1301
|
return matching(columns)
|
|
1320
1302
|
}, [columns, query])
|
|
1321
1303
|
|
|
1322
|
-
// The fields a quick control already owns, listed under their own heading
|
|
1323
|
-
// below the rest. They are the only route to an operator the control cannot
|
|
1324
|
-
// express, and burying them entirely is what left `status is not active`
|
|
1325
|
-
// unbuildable from anywhere.
|
|
1326
|
-
const visibleQuickFields = useMemo(() => {
|
|
1327
|
-
const offered = allColumns.filter((c) => quickFilterKeys.has(c.key))
|
|
1328
|
-
const needle = query.trim().toLowerCase()
|
|
1329
|
-
return needle ? offered.filter((c) => c.label.toLowerCase().includes(needle)) : offered
|
|
1330
|
-
}, [allColumns, quickFilterKeys, query])
|
|
1331
|
-
|
|
1332
1304
|
function chooseField(key: string) {
|
|
1333
|
-
const col =
|
|
1305
|
+
const col = columns.find((c) => c.key === key)
|
|
1334
1306
|
const firstOperator = getOperators(col?.filterType ?? 'string')[0].value
|
|
1335
1307
|
setField(key)
|
|
1336
1308
|
setOperator(firstOperator)
|
|
@@ -1507,32 +1479,6 @@ function FilterPanel({
|
|
|
1507
1479
|
</button>
|
|
1508
1480
|
))}
|
|
1509
1481
|
|
|
1510
|
-
{/* The fields that already have a control on the row. Set apart under
|
|
1511
|
-
a heading rather than mixed in, because picking one here means
|
|
1512
|
-
something narrower than picking any field above: not "filter by
|
|
1513
|
-
status" — the control does that — but "filter by status in a way
|
|
1514
|
-
the control cannot say". */}
|
|
1515
|
-
{!field && visibleQuickFields.length > 0 && (
|
|
1516
|
-
<>
|
|
1517
|
-
{visibleFields.length > 0 && <div className="my-1 border-t" />}
|
|
1518
|
-
<p className="px-2 py-1.5 text-xs text-muted-foreground">
|
|
1519
|
-
{t('admin.components.table_toolbar.more_conditions')}
|
|
1520
|
-
</p>
|
|
1521
|
-
{visibleQuickFields.map((col) => (
|
|
1522
|
-
<button
|
|
1523
|
-
key={col.key}
|
|
1524
|
-
type="button"
|
|
1525
|
-
data-slot="filter-panel-item"
|
|
1526
|
-
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
|
1527
|
-
onClick={() => chooseField(col.key)}
|
|
1528
|
-
>
|
|
1529
|
-
{col.label}
|
|
1530
|
-
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
|
1531
|
-
</button>
|
|
1532
|
-
))}
|
|
1533
|
-
</>
|
|
1534
|
-
)}
|
|
1535
|
-
|
|
1536
1482
|
{/* Stage two, list-shaped types: the values themselves, one click to
|
|
1537
1483
|
apply. No operator control — "is" is the only thing a list of
|
|
1538
1484
|
values can mean, and offering "is not" here would double the list's
|
|
@@ -1737,7 +1683,7 @@ function FilterPanel({
|
|
|
1737
1683
|
|
|
1738
1684
|
{/* Nothing matched the search — say so rather than showing a blank
|
|
1739
1685
|
panel that reads as broken. */}
|
|
1740
|
-
{((!field && visibleFields.length === 0
|
|
1686
|
+
{((!field && visibleFields.length === 0) ||
|
|
1741
1687
|
(field && listed && !multi && listOptions.length === 0)) && (
|
|
1742
1688
|
<p className="px-2 py-6 text-center text-muted-foreground">
|
|
1743
1689
|
{t('admin.common.no_results')}
|
|
@@ -11,7 +11,11 @@ import { Link, useParams, useRouterState } from '@tanstack/react-router'
|
|
|
11
11
|
import { Fragment, type ReactNode, useMemo } from 'react'
|
|
12
12
|
import { useTranslation } from 'react-i18next'
|
|
13
13
|
import { isPathWithin, type NavEntry, resolveNavLabel, useNavEntries } from '../lib/nav-registry'
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
resolveSettingsBreadcrumbLabel,
|
|
16
|
+
type SettingsNavEntry,
|
|
17
|
+
useSettingsNav,
|
|
18
|
+
} from '../lib/settings-nav-registry'
|
|
15
19
|
|
|
16
20
|
interface Crumb {
|
|
17
21
|
label: string
|
|
@@ -190,7 +194,10 @@ function buildCrumbs({
|
|
|
190
194
|
const crumbs: Crumb[] = [{ label: t('admin.settings_page.title'), to: settingsRoot }]
|
|
191
195
|
const entry = bestMatch(settings, (e) => `${settingsRoot}${e.path}`, pathname)
|
|
192
196
|
if (entry) {
|
|
193
|
-
crumbs.push({
|
|
197
|
+
crumbs.push({
|
|
198
|
+
label: resolveSettingsBreadcrumbLabel(entry, t),
|
|
199
|
+
to: `${settingsRoot}${entry.path}`,
|
|
200
|
+
})
|
|
194
201
|
}
|
|
195
202
|
return crumbs
|
|
196
203
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { toastManager } from '@spree/dashboard-ui'
|
|
2
2
|
import { useTranslation } from 'react-i18next'
|
|
3
|
-
import {
|
|
3
|
+
import { getApiClient } from '../api-client'
|
|
4
4
|
import { i18n, markGenuineLocaleChoice, switchLocale } from '../lib/i18n'
|
|
5
5
|
import { useAuth } from './use-auth'
|
|
6
6
|
|
|
@@ -36,8 +36,17 @@ export function useSwitchAdminLocale() {
|
|
|
36
36
|
return async (code: string): Promise<void> => {
|
|
37
37
|
// Already adopted on the account AND on screen — nothing to do.
|
|
38
38
|
if (code === user?.selected_locale && code === i18n.language) return
|
|
39
|
+
// Resolved OUTSIDE the try: `getApiClient` throws a named error when no
|
|
40
|
+
// client is registered, and that is a misconfigured host rather than a
|
|
41
|
+
// failed save — caught below it would surface as "we could not save your
|
|
42
|
+
// language", which sends the reader looking in the wrong place.
|
|
43
|
+
//
|
|
44
|
+
// Through the registered client rather than `adminClient`: this hook runs
|
|
45
|
+
// in the account menu, which both panels mount, and a seller's language
|
|
46
|
+
// change sent to the Admin API is a request they hold no credential for.
|
|
47
|
+
const { updateAccount } = getApiClient()
|
|
39
48
|
try {
|
|
40
|
-
const { user: updated } = await
|
|
49
|
+
const { user: updated } = await updateAccount({ selected_locale: code })
|
|
41
50
|
updateUser(updated)
|
|
42
51
|
if (code !== i18n.language) {
|
|
43
52
|
// Displayed language changes — persist the genuine choice + reload.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every ISO 4217 currency code the runtime knows about — the active-currency
|
|
3
|
+
* counterpart to the Rails admin's `Money::Currency.table` list. Used for
|
|
4
|
+
* contexts where the merchant can pick *any* currency (a market's currency)
|
|
5
|
+
* rather than one the store already supports. Degrades to an empty list on
|
|
6
|
+
* runtimes without `Intl.supportedValuesOf`, so callers fall back to
|
|
7
|
+
* `supported_currencies`.
|
|
8
|
+
*/
|
|
9
|
+
export const ALL_CURRENCY_CODES: string[] = (() => {
|
|
10
|
+
try {
|
|
11
|
+
return Intl.supportedValuesOf('currency')
|
|
12
|
+
} catch {
|
|
13
|
+
return []
|
|
14
|
+
}
|
|
15
|
+
})()
|
package/src/lib/form-mappers.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { PanelAccountParams } from '../api-client'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Treat a blank or whitespace-only string as "not set" — common when mapping
|
|
3
5
|
* form values to API params, where a blank `<input>` should become `undefined`
|
|
@@ -104,3 +106,41 @@ export function editableAddressToStockLocationParams(values: {
|
|
|
104
106
|
phone: values.phone,
|
|
105
107
|
}
|
|
106
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The signed-in person's own account, mapped from either panel's edit form to
|
|
112
|
+
* the params its API accepts.
|
|
113
|
+
*
|
|
114
|
+
* Shared because the three rules here are the same on both panels and each is
|
|
115
|
+
* easy to get subtly wrong:
|
|
116
|
+
*
|
|
117
|
+
* - A name is sent as typed, empty string included. Blank is how a person
|
|
118
|
+
* removes a name they once gave — the model normalizes it away — so
|
|
119
|
+
* coercing blank to `undefined` would drop the field and leave the old name
|
|
120
|
+
* in place while the form reported success.
|
|
121
|
+
* - A blank language is omitted instead: it means none was chosen, never
|
|
122
|
+
* "clear the one I have".
|
|
123
|
+
* - The photo is a three-state field — a fresh upload sends its signed id, an
|
|
124
|
+
* explicit clear sends `null` to purge the attachment, and an untouched
|
|
125
|
+
* field is left out entirely.
|
|
126
|
+
*/
|
|
127
|
+
export function accountFormToParams(values: {
|
|
128
|
+
first_name?: string | null
|
|
129
|
+
last_name?: string | null
|
|
130
|
+
selected_locale?: string | null
|
|
131
|
+
avatar_signed_id: string | null
|
|
132
|
+
avatar_cleared: boolean
|
|
133
|
+
}): PanelAccountParams {
|
|
134
|
+
return {
|
|
135
|
+
first_name: values.first_name ?? undefined,
|
|
136
|
+
last_name: values.last_name ?? undefined,
|
|
137
|
+
selected_locale: blankToUndefined(values.selected_locale),
|
|
138
|
+
...avatarParam(values.avatar_signed_id, values.avatar_cleared),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function avatarParam(signedId: string | null, cleared: boolean) {
|
|
143
|
+
if (signedId) return { avatar: signedId }
|
|
144
|
+
if (cleared) return { avatar: null }
|
|
145
|
+
return {}
|
|
146
|
+
}
|
|
@@ -82,6 +82,35 @@ describe('fulfillmentItemRows', () => {
|
|
|
82
82
|
})
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
it('names an exchange replacement by its own variant, not the original line item', () => {
|
|
86
|
+
const rows = fulfillmentItemRows(
|
|
87
|
+
fulfillment({
|
|
88
|
+
fulfillment_items: [
|
|
89
|
+
{
|
|
90
|
+
line_item_id: 'li_1',
|
|
91
|
+
variant_id: 'variant_2',
|
|
92
|
+
name: 'Blender',
|
|
93
|
+
options_text: 'White',
|
|
94
|
+
quantity: 1,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
}),
|
|
98
|
+
[lineItem({ variant_id: 'variant_1' })],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
expect(rows).toEqual([
|
|
102
|
+
{
|
|
103
|
+
key: 'li_1:variant_2',
|
|
104
|
+
lineItem: expect.objectContaining({ id: 'li_1' }),
|
|
105
|
+
name: 'Blender',
|
|
106
|
+
optionsText: 'White',
|
|
107
|
+
thumbnailUrl: null,
|
|
108
|
+
displayPrice: null,
|
|
109
|
+
quantity: 1,
|
|
110
|
+
},
|
|
111
|
+
])
|
|
112
|
+
})
|
|
113
|
+
|
|
85
114
|
it('keys an item with no line item by its variant so it still renders', () => {
|
|
86
115
|
const rows = fulfillmentItemRows(
|
|
87
116
|
fulfillment({
|