@spree/dashboard-core 0.13.0 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +97 -36
- package/src/components/import-wizard-dialog.tsx +710 -0
- package/src/components/locale-select.tsx +70 -57
- package/src/components/media-picker-sheet.tsx +307 -0
- package/src/components/mobile-top-bar.tsx +75 -0
- package/src/components/nav-main.tsx +197 -27
- package/src/components/page-header.tsx +203 -61
- package/src/components/page-tabs.tsx +4 -1
- package/src/components/preferences-form.tsx +98 -4
- package/src/components/resource-combobox.tsx +52 -4
- package/src/components/resource-picker-sheet.tsx +209 -73
- package/src/components/resource-table.tsx +425 -131
- package/src/components/route-announcer.tsx +64 -0
- package/src/components/section-heading.tsx +95 -0
- package/src/components/settings-sidebar.tsx +290 -60
- package/src/components/sheet-top-bar.tsx +44 -0
- package/src/components/sidebar-search.tsx +83 -0
- package/src/components/sidebar-user.tsx +213 -0
- package/src/components/skip-link.tsx +36 -0
- package/src/components/store-date-picker.tsx +6 -2
- package/src/components/store-switcher.tsx +63 -22
- package/src/components/table-toolbar.tsx +1373 -442
- package/src/components/tag-combobox.tsx +12 -4
- package/src/components/top-bar-breadcrumbs.tsx +235 -0
- package/src/components/wizard-close-guard.test.ts +54 -0
- package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
- package/src/hooks/use-command-palette.tsx +13 -0
- package/src/hooks/use-countries.test.ts +56 -0
- package/src/hooks/use-countries.ts +51 -5
- package/src/hooks/use-direct-upload.ts +16 -3
- package/src/hooks/use-export.ts +51 -16
- package/src/hooks/use-import.ts +261 -14
- package/src/hooks/use-package-types.ts +132 -0
- package/src/hooks/use-resource-mutation.ts +74 -13
- package/src/hooks/use-stock-locations.ts +131 -0
- package/src/hooks/use-switch-admin-locale.ts +2 -2
- package/src/hooks/use-visual-viewport-offset.ts +61 -0
- package/src/index.ts +45 -1
- package/src/lib/cancel-order-form.ts +27 -0
- package/src/lib/create-action-registry.ts +278 -0
- package/src/lib/date-presets.test.ts +86 -0
- package/src/lib/date-presets.ts +98 -0
- package/src/lib/delivery-form.ts +15 -0
- package/src/lib/docs.ts +32 -0
- package/src/lib/download.ts +13 -1
- package/src/lib/filters-to-ransack.test.ts +113 -0
- package/src/lib/filters-to-ransack.ts +22 -1
- package/src/lib/form-errors.ts +122 -10
- package/src/lib/form-mappers.ts +76 -0
- package/src/lib/fulfillment-items.test.ts +213 -0
- package/src/lib/fulfillment-items.ts +171 -0
- package/src/lib/i18n.ts +19 -11
- package/src/lib/import-types.ts +25 -0
- package/src/lib/label-upload-form.ts +22 -0
- package/src/lib/nav-registry.ts +71 -3
- package/src/lib/permissions.ts +46 -1
- package/src/lib/progress.ts +18 -0
- package/src/lib/query-keys.ts +13 -13
- package/src/lib/sample-csv.ts +21 -0
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/storefront.ts +19 -0
- package/src/lib/table-registry.ts +56 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -516
- package/src/locales/de.json +1883 -515
- package/src/locales/en.json +1886 -516
- package/src/locales/fr.json +1884 -516
- package/src/locales/pl.json +1913 -516
- package/src/locales/zh-CN.json +1883 -516
- package/src/pages/package-types.tsx +484 -0
- package/src/pages/settings-index.tsx +112 -0
- package/src/pages/stock-locations.tsx +674 -0
- package/src/plugin.ts +29 -0
- package/src/products/add-video-dialog.tsx +100 -0
- package/src/products/bulk-variants-dialog.tsx +301 -0
- package/src/products/carton-size.test.ts +51 -0
- package/src/products/carton-size.ts +20 -0
- package/src/products/currency-parts.ts +21 -0
- package/src/products/inventory-section.tsx +259 -0
- package/src/products/media-edit-sheet.tsx +336 -0
- package/src/products/normalize-customs.test.ts +39 -0
- package/src/products/normalize-customs.ts +27 -0
- package/src/products/normalize-money.test.ts +58 -0
- package/src/products/normalize-money.ts +73 -0
- package/src/products/normalize-quantity.test.ts +32 -0
- package/src/products/normalize-quantity.ts +31 -0
- package/src/products/product-bulk-price-editor.tsx +165 -0
- package/src/products/product-form-cards.tsx +1167 -0
- package/src/products/product-form-mapping.ts +212 -0
- package/src/products/product-schema.ts +318 -0
- package/src/products/product-types.ts +130 -0
- package/src/products/use-product-form-data.ts +273 -0
- package/src/products/variant-edit-sheet.tsx +725 -0
- package/src/products/variants-matrix.test.ts +154 -0
- package/src/products/variants-matrix.ts +244 -0
- package/src/products/variants-options-builder.tsx +571 -0
- package/src/products/variants-section.tsx +535 -0
- package/src/products/video-url.test.ts +71 -0
- package/src/products/video-url.ts +83 -0
- package/src/providers/auth-provider.tsx +70 -12
- package/src/providers/permission-provider.tsx +68 -28
- package/src/providers/sticky-header-provider.tsx +134 -0
- package/src/providers/store-provider.tsx +36 -3
- package/src/providers/tenant-provider.tsx +33 -0
- package/src/schemas/package-type.ts +95 -0
- package/src/schemas/stock-location.ts +126 -0
- package/src/tables/package-types.tsx +74 -0
- package/src/tables/stock-locations.tsx +124 -0
- package/src/vite/index.ts +40 -7
- package/src/components/top-bar.tsx +0 -210
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { PaginationMeta } from '@spree/dashboard-ui'
|
|
2
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
3
|
+
import i18n from 'i18next'
|
|
4
|
+
import {
|
|
5
|
+
getApiClient,
|
|
6
|
+
type PanelStockLocation,
|
|
7
|
+
type PanelStockLocationCreateParams,
|
|
8
|
+
type PanelStockLocationParams,
|
|
9
|
+
} from '../api-client'
|
|
10
|
+
import { useResourceKey, useResourceKeyBuilder } from '../lib/query-keys'
|
|
11
|
+
import { useResourceMutation } from './use-resource-mutation'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The registered panel's stock-location resource.
|
|
15
|
+
*
|
|
16
|
+
* Throws rather than answering undefined: every hook here is only reachable
|
|
17
|
+
* from the stock-locations page, and a panel that routes to it without
|
|
18
|
+
* registering the resource is misconfigured — failing at the call site says
|
|
19
|
+
* so far more clearly than five separate "cannot read property of
|
|
20
|
+
* undefined" errors.
|
|
21
|
+
*/
|
|
22
|
+
function resource() {
|
|
23
|
+
const stockLocations = getApiClient().stockLocations
|
|
24
|
+
if (!stockLocations) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'@spree/dashboard-core: this panel registered no `stockLocations` resource, so the ' +
|
|
27
|
+
'stock-locations page cannot load. Add one in setApiClient(), or drop the route.',
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
return stockLocations
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface UseStockLocationsParams {
|
|
34
|
+
page?: number
|
|
35
|
+
limit?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// API caps `limit` at 100. The page is configurable so callers with more
|
|
39
|
+
// than 100 locations can paginate; the typical merchant has a handful.
|
|
40
|
+
export function useStockLocations({ page = 1, limit = 100 }: UseStockLocationsParams = {}) {
|
|
41
|
+
return useQuery({
|
|
42
|
+
queryKey: useResourceKey('stock-locations', { page, limit }),
|
|
43
|
+
queryFn: () => resource().list({ page, limit }),
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useStockLocation(id: string | undefined) {
|
|
48
|
+
return useQuery({
|
|
49
|
+
queryKey: useResourceKey('stock-locations', id ?? 'noop'),
|
|
50
|
+
queryFn: () => resource().get(id as string),
|
|
51
|
+
enabled: !!id,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useCreateStockLocation() {
|
|
56
|
+
return useResourceMutation<PanelStockLocation, Error, PanelStockLocationCreateParams>({
|
|
57
|
+
mutationFn: (params) => resource().create(params),
|
|
58
|
+
invalidate: [['stock-locations']],
|
|
59
|
+
successMessage: i18n.t('admin.messages.created'),
|
|
60
|
+
errorMessage: i18n.t('admin.errors.failed_to_create'),
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useUpdateStockLocation(id: string) {
|
|
65
|
+
return useResourceMutation<PanelStockLocation, Error, PanelStockLocationParams>({
|
|
66
|
+
mutationFn: (params) => resource().update(id, params),
|
|
67
|
+
invalidate: [['stock-locations'], ['stock-locations', id]],
|
|
68
|
+
successMessage: i18n.t('admin.messages.updated'),
|
|
69
|
+
errorMessage: i18n.t('admin.errors.failed_to_update'),
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Update variant that takes the id per call, for lists where every row writes
|
|
75
|
+
* to a different location (the pickup toggles on a delivery profile).
|
|
76
|
+
*/
|
|
77
|
+
export function useUpdateStockLocationById() {
|
|
78
|
+
return useResourceMutation<
|
|
79
|
+
PanelStockLocation,
|
|
80
|
+
Error,
|
|
81
|
+
{ id: string; params: PanelStockLocationParams }
|
|
82
|
+
>({
|
|
83
|
+
mutationFn: ({ id, params }) => resource().update(id, params),
|
|
84
|
+
invalidate: [['stock-locations']],
|
|
85
|
+
successMessage: i18n.t('admin.messages.updated'),
|
|
86
|
+
errorMessage: i18n.t('admin.errors.failed_to_update'),
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function useDeleteStockLocation() {
|
|
91
|
+
const queryClient = useQueryClient()
|
|
92
|
+
const buildKey = useResourceKeyBuilder()
|
|
93
|
+
|
|
94
|
+
return useResourceMutation<void, Error, string>({
|
|
95
|
+
// Guarded: `delete` is optional on the registry, and a panel whose API
|
|
96
|
+
// does not offer it should not render the action at all.
|
|
97
|
+
mutationFn: (id) => {
|
|
98
|
+
const remove = resource().delete
|
|
99
|
+
if (!remove) throw new Error('This panel cannot delete stock locations.')
|
|
100
|
+
return remove(id)
|
|
101
|
+
},
|
|
102
|
+
invalidate: [['stock-locations']],
|
|
103
|
+
successMessage: i18n.t('admin.messages.removed'),
|
|
104
|
+
errorMessage: i18n.t('admin.errors.failed_to_delete'),
|
|
105
|
+
onSuccess: (_data, id) => {
|
|
106
|
+
// Drop the individual-resource cache so any open detail view stops
|
|
107
|
+
// showing stale data after the row is gone.
|
|
108
|
+
queryClient.removeQueries({ queryKey: buildKey('stock-locations', id) })
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Whether this panel's API offers deletion at all — the operator's does, a
|
|
115
|
+
* seller's does not. Drives whether the row action is rendered, so a seller
|
|
116
|
+
* is never shown a button that cannot work.
|
|
117
|
+
*/
|
|
118
|
+
export function canDeleteStockLocations(): boolean {
|
|
119
|
+
return typeof getApiClient().stockLocations?.delete === 'function'
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* One page of locations, for `ResourceTable`'s own query. Not a hook — the
|
|
124
|
+
* table calls it with the search params it has already parsed.
|
|
125
|
+
*/
|
|
126
|
+
export function listStockLocations(params: Record<string, unknown>) {
|
|
127
|
+
return resource().list(params) as Promise<{
|
|
128
|
+
data: PanelStockLocation[]
|
|
129
|
+
meta: PaginationMeta
|
|
130
|
+
}>
|
|
131
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { toastManager } from '@spree/dashboard-ui'
|
|
1
2
|
import { useTranslation } from 'react-i18next'
|
|
2
|
-
import { toast } from 'sonner'
|
|
3
3
|
import { adminClient } from '../client'
|
|
4
4
|
import { i18n, markGenuineLocaleChoice, switchLocale } from '../lib/i18n'
|
|
5
5
|
import { useAuth } from './use-auth'
|
|
@@ -49,7 +49,7 @@ export function useSwitchAdminLocale() {
|
|
|
49
49
|
markGenuineLocaleChoice(code)
|
|
50
50
|
}
|
|
51
51
|
} catch {
|
|
52
|
-
|
|
52
|
+
toastManager.add({ type: 'error', title: t('admin.account.language.update_failed') })
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Distance from the bottom of the layout viewport to the bottom of the visual
|
|
5
|
+
* one, in CSS pixels.
|
|
6
|
+
*
|
|
7
|
+
* A `position: fixed; bottom: 0` element anchors to the *layout* viewport. When
|
|
8
|
+
* the page is pinch-zoomed — or a mobile browser's URL bar is expanded — the
|
|
9
|
+
* visual viewport is a smaller window onto that layout, so the element sits
|
|
10
|
+
* below what the merchant can actually see and only appears once they pan or
|
|
11
|
+
* scroll down to it. Adding this offset to `bottom` keeps a fixed bar on screen
|
|
12
|
+
* in both cases.
|
|
13
|
+
*
|
|
14
|
+
* Returns `0` when the two viewports agree, which is the ordinary desktop case
|
|
15
|
+
* and costs nothing.
|
|
16
|
+
*
|
|
17
|
+
* @param active Pass the consumer's own visibility so the measurement happens
|
|
18
|
+
* when it appears — a value read at mount is taken before the element exists
|
|
19
|
+
* and never refreshed.
|
|
20
|
+
*/
|
|
21
|
+
export function useVisualViewportOffset(active = true): number {
|
|
22
|
+
const [offset, setOffset] = useState(0)
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!active) return
|
|
26
|
+
const vv = window.visualViewport
|
|
27
|
+
if (!vv) return
|
|
28
|
+
|
|
29
|
+
const update = () => {
|
|
30
|
+
// `window.innerHeight`, not `documentElement.clientHeight`: `fixed`
|
|
31
|
+
// positioning resolves against the former, and the two disagree in
|
|
32
|
+
// exactly the situation this hook exists for — a zoomed or URL-bar-inset
|
|
33
|
+
// page reports the *visual* height as `clientHeight`, which would make
|
|
34
|
+
// the offset compute to zero and move nothing.
|
|
35
|
+
const below = window.innerHeight - (vv.offsetTop + vv.height)
|
|
36
|
+
setOffset(Math.max(0, Math.round(below)))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
update()
|
|
40
|
+
// The two viewports can still disagree on the first frame — a zoom level
|
|
41
|
+
// restored with the page, or a URL bar that has not settled, both report
|
|
42
|
+
// stale numbers until layout completes. Re-read once after paint so the
|
|
43
|
+
// bar is placed correctly without waiting for the user to resize.
|
|
44
|
+
const raf = requestAnimationFrame(update)
|
|
45
|
+
|
|
46
|
+
vv.addEventListener('resize', update)
|
|
47
|
+
vv.addEventListener('scroll', update)
|
|
48
|
+
window.addEventListener('resize', update)
|
|
49
|
+
return () => {
|
|
50
|
+
cancelAnimationFrame(raf)
|
|
51
|
+
vv.removeEventListener('resize', update)
|
|
52
|
+
vv.removeEventListener('scroll', update)
|
|
53
|
+
window.removeEventListener('resize', update)
|
|
54
|
+
}
|
|
55
|
+
// `active` is in the deps so the offset is measured when the consumer
|
|
56
|
+
// actually appears. Reading it once at mount is too early: the element is
|
|
57
|
+
// usually not rendered yet, and nothing re-measures when it later shows.
|
|
58
|
+
}, [active])
|
|
59
|
+
|
|
60
|
+
return offset
|
|
61
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
// import { AuthProvider } from '@spree/dashboard-core/providers/auth-provider'
|
|
12
12
|
// import { useResourceMutation } from '@spree/dashboard-core/hooks/use-resource-mutation'
|
|
13
13
|
|
|
14
|
+
export * from './api-client'
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// Admin SDK client (Vite-aware singleton; reads VITE_SPREE_API_URL at build)
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
17
18
|
export { adminClient } from './client'
|
|
18
19
|
export * from './components/address-form-dialog'
|
|
20
|
+
export * from './components/app-shell'
|
|
19
21
|
export * from './components/app-sidebar'
|
|
20
22
|
export * from './components/bulk-action-bar'
|
|
21
23
|
export * from './components/can'
|
|
@@ -26,8 +28,11 @@ export * from './components/export-button'
|
|
|
26
28
|
export * from './components/file-upload-field'
|
|
27
29
|
export * from './components/image-upload-field'
|
|
28
30
|
export * from './components/import-button'
|
|
31
|
+
export * from './components/import-wizard-dialog'
|
|
29
32
|
export * from './components/locale-select'
|
|
30
33
|
export * from './components/market-combobox'
|
|
34
|
+
export * from './components/media-picker-sheet'
|
|
35
|
+
export * from './components/mobile-top-bar'
|
|
31
36
|
export * from './components/nav-main'
|
|
32
37
|
export * from './components/page-header'
|
|
33
38
|
export * from './components/page-tabs'
|
|
@@ -36,17 +41,24 @@ export * from './components/resource-combobox'
|
|
|
36
41
|
export * from './components/resource-multi-autocomplete'
|
|
37
42
|
export * from './components/resource-picker-sheet'
|
|
38
43
|
export * from './components/resource-table'
|
|
44
|
+
export * from './components/route-announcer'
|
|
45
|
+
export * from './components/section-heading'
|
|
39
46
|
export * from './components/settings-sidebar'
|
|
47
|
+
export * from './components/sheet-top-bar'
|
|
48
|
+
export * from './components/sidebar-search'
|
|
49
|
+
export * from './components/sidebar-user'
|
|
50
|
+
export * from './components/skip-link'
|
|
40
51
|
export * from './components/slot'
|
|
41
52
|
export * from './components/store-date-picker'
|
|
42
53
|
export * from './components/store-switcher'
|
|
43
54
|
export * from './components/table-toolbar'
|
|
44
55
|
export * from './components/tag-combobox'
|
|
45
|
-
export * from './components/top-bar'
|
|
56
|
+
export * from './components/top-bar-breadcrumbs'
|
|
46
57
|
// ---------------------------------------------------------------------------
|
|
47
58
|
// Infra hooks
|
|
48
59
|
// ---------------------------------------------------------------------------
|
|
49
60
|
export * from './hooks/use-auth'
|
|
61
|
+
export * from './hooks/use-auto-collapse-sidebar'
|
|
50
62
|
export * from './hooks/use-command-palette'
|
|
51
63
|
export * from './hooks/use-countries'
|
|
52
64
|
export * from './hooks/use-custom-fields'
|
|
@@ -56,27 +68,40 @@ export * from './hooks/use-export'
|
|
|
56
68
|
export * from './hooks/use-global-search'
|
|
57
69
|
export * from './hooks/use-host-form'
|
|
58
70
|
export * from './hooks/use-import'
|
|
71
|
+
export * from './hooks/use-package-types'
|
|
59
72
|
export * from './hooks/use-resource-mutation'
|
|
73
|
+
export * from './hooks/use-stock-locations'
|
|
60
74
|
export * from './hooks/use-switch-admin-locale'
|
|
75
|
+
export * from './hooks/use-visual-viewport-offset'
|
|
76
|
+
export * from './lib/cancel-order-form'
|
|
77
|
+
export * from './lib/create-action-registry'
|
|
61
78
|
// ---------------------------------------------------------------------------
|
|
62
79
|
// Registries — pluggable extension points (nav, route, slot, table,
|
|
63
80
|
// settings-nav, form fields, custom field components)
|
|
64
81
|
// ---------------------------------------------------------------------------
|
|
65
82
|
export * from './lib/custom-field-components'
|
|
83
|
+
export * from './lib/date-presets'
|
|
84
|
+
export * from './lib/delivery-form'
|
|
66
85
|
// ---------------------------------------------------------------------------
|
|
67
86
|
// Helpers
|
|
68
87
|
// ---------------------------------------------------------------------------
|
|
88
|
+
export * from './lib/docs'
|
|
89
|
+
export * from './lib/download'
|
|
69
90
|
export * from './lib/filters-to-ransack'
|
|
70
91
|
export * from './lib/form-errors'
|
|
71
92
|
export * from './lib/form-fields-registry'
|
|
72
93
|
export * from './lib/form-mappers'
|
|
73
94
|
export * from './lib/formatters'
|
|
95
|
+
export * from './lib/fulfillment-items'
|
|
74
96
|
// i18n side-effect import bootstraps i18next + React adapter on first import.
|
|
75
97
|
// Consumers that need the singleton (e.g. plugin authors calling
|
|
76
98
|
// `i18n.addResourceBundle`) get it via the named re-export.
|
|
77
99
|
export * from './lib/i18n'
|
|
100
|
+
export * from './lib/import-types'
|
|
101
|
+
export * from './lib/label-upload-form'
|
|
78
102
|
export * from './lib/nav-registry'
|
|
79
103
|
export * from './lib/permissions'
|
|
104
|
+
export * from './lib/progress'
|
|
80
105
|
export * from './lib/query-client'
|
|
81
106
|
export * from './lib/query-keys'
|
|
82
107
|
export * from './lib/route-registry'
|
|
@@ -85,13 +110,32 @@ export * from './lib/settings-nav-registry'
|
|
|
85
110
|
export * from './lib/slot-registry'
|
|
86
111
|
export * from './lib/table-registry'
|
|
87
112
|
export { ensureTimestampColumns } from './lib/timestamp-columns'
|
|
113
|
+
export * from './lib/type-labels'
|
|
114
|
+
export * from './pages/package-types'
|
|
115
|
+
export * from './pages/settings-index'
|
|
116
|
+
export * from './pages/stock-locations'
|
|
88
117
|
// ---------------------------------------------------------------------------
|
|
89
118
|
// Plugin facade — re-exported for convenience; same API as `/plugin` subpath
|
|
90
119
|
// ---------------------------------------------------------------------------
|
|
91
120
|
export * from './plugin'
|
|
121
|
+
export * from './products/currency-parts'
|
|
122
|
+
export * from './products/normalize-customs'
|
|
123
|
+
export * from './products/normalize-money'
|
|
124
|
+
export * from './products/normalize-quantity'
|
|
125
|
+
export * from './products/product-form-cards'
|
|
126
|
+
export * from './products/product-form-mapping'
|
|
127
|
+
export * from './products/product-schema'
|
|
128
|
+
export * from './products/product-types'
|
|
129
|
+
export * from './products/use-product-form-data'
|
|
130
|
+
export * from './products/variants-matrix'
|
|
131
|
+
export * from './products/video-url'
|
|
92
132
|
// ---------------------------------------------------------------------------
|
|
93
133
|
// Providers — must be mounted by the consuming app shell
|
|
94
134
|
// ---------------------------------------------------------------------------
|
|
95
135
|
export * from './providers/auth-provider'
|
|
96
136
|
export * from './providers/permission-provider'
|
|
137
|
+
export * from './providers/sticky-header-provider'
|
|
97
138
|
export * from './providers/store-provider'
|
|
139
|
+
export * from './providers/tenant-provider'
|
|
140
|
+
export * from './schemas/package-type'
|
|
141
|
+
export * from './schemas/stock-location'
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Why an order is being cancelled, and what happens to the money and the
|
|
5
|
+
* customer.
|
|
6
|
+
*
|
|
7
|
+
* Both toggles are always present in the form; which of them a surface
|
|
8
|
+
* actually renders is its own business — the seller branch accepts no refund
|
|
9
|
+
* argument at all, so it never shows that one.
|
|
10
|
+
*/
|
|
11
|
+
export const cancelOrderFormSchema = z.object({
|
|
12
|
+
cancel_reason_id: z.string(),
|
|
13
|
+
cancel_note: z.string(),
|
|
14
|
+
refund_payments: z.boolean(),
|
|
15
|
+
notify_customer: z.boolean(),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export type CancelOrderFormValues = z.infer<typeof cancelOrderFormSchema>
|
|
19
|
+
|
|
20
|
+
export const CANCEL_ORDER_DEFAULTS: CancelOrderFormValues = {
|
|
21
|
+
cancel_reason_id: '',
|
|
22
|
+
cancel_note: '',
|
|
23
|
+
// Off by default, like the API: releasing the gateway's hold is automatic,
|
|
24
|
+
// but handing back money already taken is the merchant's call.
|
|
25
|
+
refund_payments: false,
|
|
26
|
+
notify_customer: false,
|
|
27
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import type { LucideIcon } from 'lucide-react'
|
|
2
|
+
import { useSyncExternalStore } from 'react'
|
|
3
|
+
import type { SubjectName } from './permissions'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Registry for the command palette's create actions — the rows that turn
|
|
7
|
+
* "add product" or "new order" into a jump to the matching create page.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors `search-registry.ts` and `nav-registry.ts`: a module-singleton
|
|
10
|
+
* populated at app boot by side-effect modules, read reactively via
|
|
11
|
+
* `useCreateActions`. Plugins register their own create pages the same way.
|
|
12
|
+
*
|
|
13
|
+
* Matching is a concern of `matchCreateActions` below, not of the entry: an
|
|
14
|
+
* entry only declares what it is called and where it goes.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface CreateActionEntry {
|
|
18
|
+
/** Stable identifier — register/remove key, React key, and result-value prefix. */
|
|
19
|
+
key: string
|
|
20
|
+
/**
|
|
21
|
+
* i18n key for the singular resource noun, as the merchant would type it
|
|
22
|
+
* ("Product", "Customer"). Resolved with `t` at match time so typing the
|
|
23
|
+
* noun works in the merchant's own language.
|
|
24
|
+
*/
|
|
25
|
+
labelKey: string
|
|
26
|
+
/**
|
|
27
|
+
* Extra i18n keys for words that should also match this action — plurals,
|
|
28
|
+
* synonyms, and abbreviations ("SKU" for a product). Optional.
|
|
29
|
+
*/
|
|
30
|
+
aliasKeys?: string[]
|
|
31
|
+
/** Row icon. */
|
|
32
|
+
icon?: LucideIcon
|
|
33
|
+
/** CanCanCan subject gating the action. Checked with `create`, not `read`. */
|
|
34
|
+
subject?: SubjectName
|
|
35
|
+
/** Lower numbers render first among equally-good matches. */
|
|
36
|
+
position?: number
|
|
37
|
+
/**
|
|
38
|
+
* Destination, given the active store. Either a dedicated create route
|
|
39
|
+
* (`/${storeId}/products/new`) or an index page with the create sheet open
|
|
40
|
+
* (`/${storeId}/customers?new=true`).
|
|
41
|
+
*/
|
|
42
|
+
getRoute: (storeId: string) => { to: string; search?: Record<string, unknown> }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface CreateActionMutator {
|
|
46
|
+
/** Register an action. Throws if the key is already registered. */
|
|
47
|
+
add(entry: CreateActionEntry): void
|
|
48
|
+
/** Remove an action by key. No-op when missing. */
|
|
49
|
+
remove(key: string): void
|
|
50
|
+
/** Patch an existing action. Throws if the key is missing. */
|
|
51
|
+
update(key: string, patch: Partial<Omit<CreateActionEntry, 'key'>>): void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const entries: CreateActionEntry[] = []
|
|
55
|
+
const listeners = new Set<() => void>()
|
|
56
|
+
let snapshotCache: CreateActionEntry[] | null = null
|
|
57
|
+
|
|
58
|
+
function notify() {
|
|
59
|
+
snapshotCache = null
|
|
60
|
+
for (const l of listeners) l()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The create-action registry. Every mutation notifies subscribers, so a page
|
|
65
|
+
* rendered through `useCreateActions` reflects it immediately — which is how a
|
|
66
|
+
* plugin can register its create pages after the app has booted.
|
|
67
|
+
*
|
|
68
|
+
* `add` throws on a duplicate key and `update` throws on an unknown one, so a
|
|
69
|
+
* plugin colliding with a built-in fails loudly instead of silently shadowing
|
|
70
|
+
* it. `remove` is tolerant, letting teardown run unconditionally.
|
|
71
|
+
*/
|
|
72
|
+
export const createActionRegistry: CreateActionMutator = {
|
|
73
|
+
add(entry) {
|
|
74
|
+
if (entries.some((e) => e.key === entry.key)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Create action "${entry.key}" already registered. Use createActionRegistry.update().`,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
entries.push(entry)
|
|
80
|
+
notify()
|
|
81
|
+
},
|
|
82
|
+
remove(key) {
|
|
83
|
+
const i = entries.findIndex((e) => e.key === key)
|
|
84
|
+
if (i === -1) return
|
|
85
|
+
entries.splice(i, 1)
|
|
86
|
+
notify()
|
|
87
|
+
},
|
|
88
|
+
update(key, patch) {
|
|
89
|
+
const e = entries.find((x) => x.key === key)
|
|
90
|
+
if (!e) throw new Error(`Create action "${key}" not found.`)
|
|
91
|
+
Object.assign(e, patch)
|
|
92
|
+
notify()
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function subscribe(listener: () => void): () => void {
|
|
97
|
+
listeners.add(listener)
|
|
98
|
+
return () => {
|
|
99
|
+
listeners.delete(listener)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getSnapshot(): CreateActionEntry[] {
|
|
104
|
+
if (!snapshotCache) {
|
|
105
|
+
snapshotCache = [...entries].sort((a, b) => (a.position ?? 100) - (b.position ?? 100))
|
|
106
|
+
}
|
|
107
|
+
return snapshotCache
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Reactively read the sorted create actions. Re-renders on registry mutation. */
|
|
111
|
+
export function useCreateActions(): CreateActionEntry[] {
|
|
112
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Test-only: clear the registry. */
|
|
116
|
+
export function __resetCreateActionRegistry(): void {
|
|
117
|
+
entries.length = 0
|
|
118
|
+
notify()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Matching
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* i18n key holding the create verbs for the active language, as a
|
|
127
|
+
* comma-separated list ("add,new,create"). A list rather than one word because
|
|
128
|
+
* merchants reach for whichever verb comes to mind, and several languages have
|
|
129
|
+
* more than one natural phrasing.
|
|
130
|
+
*/
|
|
131
|
+
export const CREATE_VERBS_KEY = 'admin.components.command_palette.create.verbs'
|
|
132
|
+
|
|
133
|
+
export interface CreateActionMatch {
|
|
134
|
+
entry: CreateActionEntry
|
|
135
|
+
/** The resolved noun that matched, for rendering the row's label. */
|
|
136
|
+
noun: string
|
|
137
|
+
/** Lower sorts first: an exact noun match beats a prefix beats a substring. */
|
|
138
|
+
rank: number
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Scripts written without spaces between words. Restricting the unspaced verb
|
|
143
|
+
* match to these keeps it from firing on space-delimited languages, where
|
|
144
|
+
* "address" would otherwise read as the verb "add" plus a noun "ress".
|
|
145
|
+
*/
|
|
146
|
+
const UNSPACED_SCRIPT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Strip a leading or trailing verb from an unspaced query, returning the noun
|
|
150
|
+
* that remains (`''` when the query is just the verb). Returns `null` when the
|
|
151
|
+
* query carries no verb or isn't written in an unspaced script.
|
|
152
|
+
*/
|
|
153
|
+
function stripUnspacedVerb(query: string, verbs: Set<string>): string | null {
|
|
154
|
+
if (!UNSPACED_SCRIPT.test(query)) return null
|
|
155
|
+
|
|
156
|
+
// Longest first, so a verb that is a prefix of another ("新" vs "新建")
|
|
157
|
+
// can't strip the shorter match and leave the rest of the verb in the noun.
|
|
158
|
+
const byLength = [...verbs].sort((a, b) => b.length - a.length)
|
|
159
|
+
|
|
160
|
+
for (const verb of byLength) {
|
|
161
|
+
if (query === verb) return ''
|
|
162
|
+
if (query.startsWith(verb)) return query.slice(verb.length)
|
|
163
|
+
if (query.endsWith(verb)) return query.slice(0, -verb.length)
|
|
164
|
+
}
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Fold case and strip accents so "Rückgabe" matches "ruckgabe". */
|
|
169
|
+
function normalize(value: string): string {
|
|
170
|
+
return value
|
|
171
|
+
.toLowerCase()
|
|
172
|
+
.normalize('NFD')
|
|
173
|
+
.replace(/\p{Diacritic}/gu, '')
|
|
174
|
+
.trim()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Turn a raw palette query into the create actions it names.
|
|
179
|
+
*
|
|
180
|
+
* Matches "<verb> <noun>" in either order, since languages disagree on which
|
|
181
|
+
* comes first — English puts the verb first ("add product"), while a merchant
|
|
182
|
+
* typing Polish or German may not. A bare verb ("new") lists everything
|
|
183
|
+
* creatable, which doubles as discovery of what this dashboard can create.
|
|
184
|
+
*
|
|
185
|
+
* Returns an empty array when the query names no verb, so ordinary searches are
|
|
186
|
+
* unaffected.
|
|
187
|
+
*/
|
|
188
|
+
/**
|
|
189
|
+
* The create actions a query names, narrowed to those the signed-in user may
|
|
190
|
+
* actually create.
|
|
191
|
+
*
|
|
192
|
+
* Create is gated on the `create` action rather than `read`: a user who can
|
|
193
|
+
* list products but not add one should never be offered "New product". Entries
|
|
194
|
+
* without a subject are ungated.
|
|
195
|
+
*/
|
|
196
|
+
export function permittedCreateActions({
|
|
197
|
+
query,
|
|
198
|
+
entries,
|
|
199
|
+
t,
|
|
200
|
+
can,
|
|
201
|
+
}: {
|
|
202
|
+
query: string
|
|
203
|
+
entries: CreateActionEntry[]
|
|
204
|
+
t: (key: string) => string
|
|
205
|
+
can: (action: string, subject: string) => boolean
|
|
206
|
+
}): CreateActionMatch[] {
|
|
207
|
+
return matchCreateActions({ query, entries, t }).filter(
|
|
208
|
+
({ entry }) => !entry.subject || can('create', entry.subject),
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function matchCreateActions({
|
|
213
|
+
query,
|
|
214
|
+
entries,
|
|
215
|
+
t,
|
|
216
|
+
}: {
|
|
217
|
+
query: string
|
|
218
|
+
entries: CreateActionEntry[]
|
|
219
|
+
t: (key: string) => string
|
|
220
|
+
}): CreateActionMatch[] {
|
|
221
|
+
const words = normalize(query).split(/\s+/).filter(Boolean)
|
|
222
|
+
if (words.length === 0) return []
|
|
223
|
+
|
|
224
|
+
const verbs = new Set(
|
|
225
|
+
t(CREATE_VERBS_KEY)
|
|
226
|
+
.split(',')
|
|
227
|
+
.map((verb) => normalize(verb))
|
|
228
|
+
.filter(Boolean),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
// The verb may lead or trail; strip whichever end carries it and treat the
|
|
232
|
+
// rest as the noun. Checking the last word only when the first didn't match
|
|
233
|
+
// keeps "add" + "add-on"-style nouns unambiguous.
|
|
234
|
+
let noun: string
|
|
235
|
+
if (verbs.has(words[0])) {
|
|
236
|
+
noun = words.slice(1).join(' ')
|
|
237
|
+
} else if (words.length > 1 && verbs.has(words[words.length - 1])) {
|
|
238
|
+
noun = words.slice(0, -1).join(' ')
|
|
239
|
+
} else {
|
|
240
|
+
// Languages that don't put spaces between words — Chinese, Japanese —
|
|
241
|
+
// arrive as one token ("新建商品"), so word equality never matches. Fall
|
|
242
|
+
// back to stripping the verb as a prefix or suffix of the whole query.
|
|
243
|
+
const unspaced = stripUnspacedVerb(normalize(query), verbs)
|
|
244
|
+
if (unspaced === null) return []
|
|
245
|
+
noun = unspaced
|
|
246
|
+
}
|
|
247
|
+
const matches: CreateActionMatch[] = []
|
|
248
|
+
|
|
249
|
+
for (const entry of entries) {
|
|
250
|
+
const labels = [entry.labelKey, ...(entry.aliasKeys ?? [])].map((key) => t(key))
|
|
251
|
+
let best: { rank: number; noun: string } | null = null
|
|
252
|
+
|
|
253
|
+
for (const label of labels) {
|
|
254
|
+
const candidate = normalize(label)
|
|
255
|
+
// A bare verb lists everything; otherwise rank by how squarely the typed
|
|
256
|
+
// noun lands on this label.
|
|
257
|
+
const rank = !noun
|
|
258
|
+
? 3
|
|
259
|
+
: candidate === noun
|
|
260
|
+
? 0
|
|
261
|
+
: candidate.startsWith(noun)
|
|
262
|
+
? 1
|
|
263
|
+
: candidate.includes(noun)
|
|
264
|
+
? 2
|
|
265
|
+
: -1
|
|
266
|
+
if (rank === -1) continue
|
|
267
|
+
// Always report the primary label, so a row found via an alias still
|
|
268
|
+
// reads as the resource's real name.
|
|
269
|
+
if (!best || rank < best.rank) best = { rank, noun: labels[0] }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (best) matches.push({ entry, noun: best.noun, rank: best.rank })
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return matches.sort(
|
|
276
|
+
(a, b) => a.rank - b.rank || (a.entry.position ?? 100) - (b.entry.position ?? 100),
|
|
277
|
+
)
|
|
278
|
+
}
|