@spree/dashboard-core 0.13.1 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +52 -27
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +383 -127
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +19 -14
  36. package/src/components/table-toolbar.tsx +1357 -440
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +241 -16
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +9 -6
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/table-registry.ts +50 -0
  77. package/src/lib/type-labels.test.ts +68 -0
  78. package/src/lib/type-labels.ts +78 -0
  79. package/src/locales/ar.json +1889 -517
  80. package/src/locales/de.json +1884 -517
  81. package/src/locales/en.json +1886 -519
  82. package/src/locales/fr.json +1884 -517
  83. package/src/locales/pl.json +1913 -517
  84. package/src/locales/zh-CN.json +1883 -517
  85. package/src/pages/package-types.tsx +484 -0
  86. package/src/pages/settings-index.tsx +112 -0
  87. package/src/pages/stock-locations.tsx +674 -0
  88. package/src/plugin.ts +29 -0
  89. package/src/products/add-video-dialog.tsx +100 -0
  90. package/src/products/bulk-variants-dialog.tsx +301 -0
  91. package/src/products/carton-size.test.ts +51 -0
  92. package/src/products/carton-size.ts +20 -0
  93. package/src/products/currency-parts.ts +21 -0
  94. package/src/products/inventory-section.tsx +259 -0
  95. package/src/products/media-edit-sheet.tsx +336 -0
  96. package/src/products/normalize-customs.test.ts +39 -0
  97. package/src/products/normalize-customs.ts +27 -0
  98. package/src/products/normalize-money.test.ts +58 -0
  99. package/src/products/normalize-money.ts +73 -0
  100. package/src/products/normalize-quantity.test.ts +32 -0
  101. package/src/products/normalize-quantity.ts +31 -0
  102. package/src/products/product-bulk-price-editor.tsx +165 -0
  103. package/src/products/product-form-cards.tsx +1167 -0
  104. package/src/products/product-form-mapping.ts +212 -0
  105. package/src/products/product-schema.ts +318 -0
  106. package/src/products/product-types.ts +130 -0
  107. package/src/products/use-product-form-data.ts +273 -0
  108. package/src/products/variant-edit-sheet.tsx +725 -0
  109. package/src/products/variants-matrix.test.ts +154 -0
  110. package/src/products/variants-matrix.ts +244 -0
  111. package/src/products/variants-options-builder.tsx +571 -0
  112. package/src/products/variants-section.tsx +535 -0
  113. package/src/products/video-url.test.ts +71 -0
  114. package/src/products/video-url.ts +83 -0
  115. package/src/providers/auth-provider.tsx +70 -12
  116. package/src/providers/permission-provider.tsx +68 -28
  117. package/src/providers/sticky-header-provider.tsx +134 -0
  118. package/src/providers/store-provider.tsx +36 -3
  119. package/src/providers/tenant-provider.tsx +33 -0
  120. package/src/schemas/package-type.ts +95 -0
  121. package/src/schemas/stock-location.ts +126 -0
  122. package/src/tables/package-types.tsx +74 -0
  123. package/src/tables/stock-locations.tsx +124 -0
  124. package/src/vite/index.ts +40 -7
  125. package/src/components/top-bar.tsx +0 -209
@@ -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
- toast.error(t('admin.account.language.update_failed'))
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
+ }