@spree/dashboard-core 0.1.0

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 (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +418 -0
  3. package/package.json +84 -0
  4. package/src/client.ts +25 -0
  5. package/src/components/address-form-dialog.tsx +376 -0
  6. package/src/components/app-sidebar.tsx +70 -0
  7. package/src/components/bulk-action-bar.tsx +504 -0
  8. package/src/components/can.tsx +30 -0
  9. package/src/components/country-combobox.tsx +238 -0
  10. package/src/components/country-state-fields.tsx +86 -0
  11. package/src/components/currency-select.tsx +181 -0
  12. package/src/components/export-button.tsx +156 -0
  13. package/src/components/file-upload-field.tsx +265 -0
  14. package/src/components/image-upload-field.tsx +53 -0
  15. package/src/components/import-button.tsx +202 -0
  16. package/src/components/locale-select.tsx +268 -0
  17. package/src/components/market-combobox.tsx +89 -0
  18. package/src/components/nav-main.tsx +186 -0
  19. package/src/components/page-header.tsx +286 -0
  20. package/src/components/page-tabs.tsx +78 -0
  21. package/src/components/preferences-form.tsx +404 -0
  22. package/src/components/resource-combobox.tsx +126 -0
  23. package/src/components/resource-multi-autocomplete.tsx +176 -0
  24. package/src/components/resource-picker-sheet.tsx +235 -0
  25. package/src/components/resource-table.tsx +652 -0
  26. package/src/components/settings-sidebar.tsx +139 -0
  27. package/src/components/slot.tsx +50 -0
  28. package/src/components/store-date-picker.tsx +18 -0
  29. package/src/components/store-switcher.tsx +70 -0
  30. package/src/components/table-toolbar.tsx +818 -0
  31. package/src/components/tag-combobox.tsx +99 -0
  32. package/src/components/top-bar.tsx +210 -0
  33. package/src/hooks/use-auth.ts +10 -0
  34. package/src/hooks/use-command-palette.tsx +34 -0
  35. package/src/hooks/use-countries.ts +15 -0
  36. package/src/hooks/use-custom-fields.ts +142 -0
  37. package/src/hooks/use-direct-upload.ts +104 -0
  38. package/src/hooks/use-display-name.ts +42 -0
  39. package/src/hooks/use-export.ts +75 -0
  40. package/src/hooks/use-global-search.ts +79 -0
  41. package/src/hooks/use-host-form.ts +34 -0
  42. package/src/hooks/use-import.ts +62 -0
  43. package/src/hooks/use-resource-mutation.ts +99 -0
  44. package/src/hooks/use-switch-admin-locale.ts +55 -0
  45. package/src/index.ts +97 -0
  46. package/src/lib/custom-field-components.ts +56 -0
  47. package/src/lib/download.ts +49 -0
  48. package/src/lib/filters-to-ransack.ts +47 -0
  49. package/src/lib/form-errors.ts +103 -0
  50. package/src/lib/form-fields-registry.ts +102 -0
  51. package/src/lib/form-mappers.ts +30 -0
  52. package/src/lib/formatters.ts +19 -0
  53. package/src/lib/i18n.ts +210 -0
  54. package/src/lib/nav-registry.ts +224 -0
  55. package/src/lib/permissions.ts +55 -0
  56. package/src/lib/query-client.ts +11 -0
  57. package/src/lib/query-keys.ts +69 -0
  58. package/src/lib/route-registry.ts +158 -0
  59. package/src/lib/search-registry.ts +129 -0
  60. package/src/lib/settings-nav-registry.ts +158 -0
  61. package/src/lib/slot-registry.ts +116 -0
  62. package/src/lib/table-registry.ts +274 -0
  63. package/src/lib/timestamp-columns.tsx +58 -0
  64. package/src/locales/ar.json +629 -0
  65. package/src/locales/de.json +629 -0
  66. package/src/locales/en.json +629 -0
  67. package/src/locales/fr.json +629 -0
  68. package/src/locales/pl.json +629 -0
  69. package/src/locales/zh-CN.json +629 -0
  70. package/src/plugin.ts +222 -0
  71. package/src/providers/auth-provider.tsx +188 -0
  72. package/src/providers/permission-provider.tsx +118 -0
  73. package/src/providers/store-provider.tsx +111 -0
  74. package/src/virtual.d.ts +5 -0
  75. package/src/vite/discover.ts +170 -0
  76. package/src/vite/index.ts +270 -0
@@ -0,0 +1,75 @@
1
+ import type { Export, ExportCreateParams } from '@spree/admin-sdk'
2
+ import { useMutation } from '@tanstack/react-query'
3
+ import { toast } from 'sonner'
4
+ import { adminClient } from '../client'
5
+ import { downloadFromApi } from '../lib/download'
6
+ import { i18n } from '../lib/i18n'
7
+ import { useAuth } from './use-auth'
8
+
9
+ const POLL_INTERVAL_MS = 2000
10
+ const POLL_TIMEOUT_MS = 5 * 60 * 1000
11
+
12
+ class ExportTimeoutError extends Error {
13
+ constructor() {
14
+ super('Export timed out')
15
+ this.name = 'ExportTimeoutError'
16
+ }
17
+ }
18
+
19
+ async function pollUntilDone(id: string): Promise<Export> {
20
+ const deadline = Date.now() + POLL_TIMEOUT_MS
21
+
22
+ while (Date.now() < deadline) {
23
+ const exp = await adminClient.exports.get(id)
24
+ if (exp.done) return exp
25
+ await new Promise<void>((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
26
+ }
27
+
28
+ throw new ExportTimeoutError()
29
+ }
30
+
31
+ async function downloadExportFile(exp: Export, token: string | null): Promise<void> {
32
+ if (!exp.download_url) throw new Error('Export has no download_url')
33
+
34
+ await downloadFromApi(token, exp.download_url, exp.filename ?? 'export.csv')
35
+ }
36
+
37
+ /**
38
+ * Queue a CSV export and drive it to completion: create → poll → download.
39
+ * On poll timeout, falls back to the email-link path —
40
+ * `Spree::ExportMailer.export_done` already covers that server-side.
41
+ */
42
+ export function useExport() {
43
+ const { token } = useAuth()
44
+
45
+ return useMutation({
46
+ mutationFn: async (params: ExportCreateParams) => {
47
+ // Per-invocation id so concurrent exports don't collide on a single
48
+ // sticky toast.
49
+ const toastId = `export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
50
+ toast.loading(i18n.t('admin.components.export_button.preparing'), { id: toastId })
51
+
52
+ try {
53
+ const created = await adminClient.exports.create(params)
54
+ const finished = await pollUntilDone(created.id)
55
+ await downloadExportFile(finished, token)
56
+ toast.success(i18n.t('admin.components.export_button.downloaded'), { id: toastId })
57
+ return finished
58
+ } catch (err) {
59
+ if (err instanceof ExportTimeoutError) {
60
+ toast.info(i18n.t('admin.components.export_button.email_fallback'), {
61
+ id: toastId,
62
+ })
63
+ } else {
64
+ toast.error(
65
+ i18n.t('admin.components.export_button.failed', {
66
+ message: err instanceof Error ? err.message : String(err),
67
+ }),
68
+ { id: toastId },
69
+ )
70
+ }
71
+ throw err
72
+ }
73
+ },
74
+ })
75
+ }
@@ -0,0 +1,79 @@
1
+ import { useQueries } from '@tanstack/react-query'
2
+ import { useEffect, useState } from 'react'
3
+ import { useAuth } from '../hooks/use-auth'
4
+ import { type SearchEntry, useSearchEntries } from '../lib/search-registry'
5
+ import { usePermissions } from '../providers/permission-provider'
6
+ import { useStore } from '../providers/store-provider'
7
+
8
+ const RESULT_LIMIT = 5
9
+ const MIN_QUERY_LENGTH = 2
10
+ const DEBOUNCE_MS = 200
11
+ const GC_TIME_MS = 60_000
12
+ const STALE_TIME_MS = 30_000
13
+
14
+ export interface SearchGroup {
15
+ entry: SearchEntry
16
+ items: unknown[]
17
+ }
18
+
19
+ /**
20
+ * One parallel `?q[search]=…&limit=5` query per registered, permitted search
21
+ * entry — products, orders, customers, promotions, and anything a plugin adds.
22
+ * Each resource defines a `search` Ransack scope server-side that does
23
+ * multi-field LIKE matching, so we pass a single `search` param and let the
24
+ * server decide what to match.
25
+ *
26
+ * The query is debounced so a fast typist doesn't fan out a wave of in-flight
27
+ * requests; results are cached briefly (and scoped by user + store) so backspace
28
+ * feels instant but they don't pile up or leak across users or stores.
29
+ */
30
+ export function useGlobalSearch(rawQuery: string) {
31
+ // Trim once: the cache key and the server query must match what `enabled`
32
+ // gates on, or " hello" and "hello" fan out as separate cache entries and the
33
+ // stray space leaks into the `cont` predicate (`LIKE %hello %`), missing rows.
34
+ const query = useDebouncedValue(rawQuery, DEBOUNCE_MS).trim()
35
+ const enabled = query.length >= MIN_QUERY_LENGTH
36
+ const entries = useSearchEntries()
37
+ const { permissions, isLoading: permissionsLoading } = usePermissions()
38
+ const { storeId } = useStore()
39
+ const { user } = useAuth()
40
+
41
+ const permitted = entries.filter((e) => !e.subject || permissions.can('read', e.subject))
42
+
43
+ const results = useQueries({
44
+ queries: permitted.map((entry) => ({
45
+ // Scope by user so a logout→login on the same store can't serve the
46
+ // previous user's cached results from React Query.
47
+ queryKey: ['cmdk', user?.id, storeId, entry.key, query],
48
+ queryFn: () => entry.fetch(query, RESULT_LIMIT),
49
+ enabled,
50
+ gcTime: GC_TIME_MS,
51
+ staleTime: STALE_TIME_MS,
52
+ })),
53
+ })
54
+
55
+ const groups: SearchGroup[] = permitted
56
+ .map((entry, i) => ({ entry, items: results[i]?.data ?? [] }))
57
+ .filter((g) => g.items.length > 0)
58
+
59
+ return {
60
+ groups,
61
+ // Treat the permission fetch as loading: until rules land, every gated
62
+ // entry is filtered out and no queries run, so without this the palette
63
+ // would flash "no results" before search has effectively started.
64
+ isLoading: enabled && (permissionsLoading || results.some((r) => r.isLoading)),
65
+ isEnabled: enabled,
66
+ hasResults: groups.length > 0,
67
+ }
68
+ }
69
+
70
+ function useDebouncedValue<T>(value: T, delayMs: number): T {
71
+ const [debounced, setDebounced] = useState(value)
72
+
73
+ useEffect(() => {
74
+ const id = setTimeout(() => setDebounced(value), delayMs)
75
+ return () => clearTimeout(id)
76
+ }, [value, delayMs])
77
+
78
+ return debounced
79
+ }
@@ -0,0 +1,34 @@
1
+ import { type FieldValues, type UseFormReturn, useFormContext } from 'react-hook-form'
2
+
3
+ /**
4
+ * The react-hook-form instance of the built-in resource form a slot widget is
5
+ * rendered inside (e.g. the product detail form for `product.form_sidebar`).
6
+ * Fields registered against it — `form.register(...)` or `<Controller>` —
7
+ * hydrate, dirty-track, and persist through the host page's own Save button;
8
+ * the widget ships no save logic of its own.
9
+ *
10
+ * Only forms that opt in provide a host form (they wrap themselves in RHF's
11
+ * `FormProvider`). Slots on pages without a page-wide form (orders,
12
+ * customers) have none — calling this there throws. Use
13
+ * `useOptionalHostForm()` for widgets that render in both kinds of context.
14
+ */
15
+ export function useHostForm<
16
+ TFieldValues extends FieldValues = FieldValues,
17
+ >(): UseFormReturn<TFieldValues> {
18
+ const form = useFormContext<TFieldValues>()
19
+ if (!form) {
20
+ throw new Error(
21
+ 'useHostForm() must be rendered inside a resource form that exposes its form context ' +
22
+ '(e.g. a product.form_sidebar slot widget). This page has no host form — ' +
23
+ 'use useOptionalHostForm() and fall back to your own state + API save.',
24
+ )
25
+ }
26
+ return form
27
+ }
28
+
29
+ /** Like {@link useHostForm}, but returns `null` when there is no host form. */
30
+ export function useOptionalHostForm<
31
+ TFieldValues extends FieldValues = FieldValues,
32
+ >(): UseFormReturn<TFieldValues> | null {
33
+ return useFormContext<TFieldValues>() ?? null
34
+ }
@@ -0,0 +1,62 @@
1
+ import type { Import, ImportCreateParams, ImportType } from '@spree/admin-sdk'
2
+ import { useMutation } from '@tanstack/react-query'
3
+ import { adminClient } from '../client'
4
+ import { downloadFromApi } from '../lib/download'
5
+ import { useStore } from '../providers/store-provider'
6
+ import { useAuth } from './use-auth'
7
+
8
+ export interface CreateImportInput {
9
+ type: ImportType
10
+ /** Signed blob id of the already direct-uploaded CSV (see `FileUploadField`). */
11
+ signedId: string
12
+ preferredDelimiter?: ImportCreateParams['preferred_delimiter']
13
+ }
14
+
15
+ /**
16
+ * Creates the import from a direct-uploaded CSV; the response is in the
17
+ * `mapping` state and carries the mapping payload.
18
+ */
19
+ export function useCreateImport() {
20
+ const { storeId } = useStore()
21
+
22
+ return useMutation({
23
+ mutationFn: ({ type, signedId, preferredDelimiter }: CreateImportInput): Promise<Import> =>
24
+ adminClient.imports.create({
25
+ type,
26
+ attachment: signedId,
27
+ preferred_delimiter: preferredDelimiter,
28
+ // The import-done email deep-links back to the wizard (`?import=<id>`
29
+ // appended server-side). Only honored when this origin is on the
30
+ // store's allowed-origins list.
31
+ results_url: `${window.location.origin}/${storeId}/settings/imports`,
32
+ }),
33
+ })
34
+ }
35
+
36
+ /** Downloads the CSV template for an import type. */
37
+ export function useDownloadImportTemplate() {
38
+ const { token } = useAuth()
39
+
40
+ return useMutation({
41
+ mutationFn: (type: ImportType) =>
42
+ downloadFromApi(
43
+ token,
44
+ `/api/v3/admin/imports/template?type=${encodeURIComponent(type)}`,
45
+ 'import_template.csv',
46
+ ),
47
+ })
48
+ }
49
+
50
+ /** Downloads the originally uploaded CSV of an import — the audit trail. */
51
+ export function useDownloadImportOriginal() {
52
+ const { token } = useAuth()
53
+
54
+ return useMutation({
55
+ mutationFn: (imp: Import) =>
56
+ downloadFromApi(
57
+ token,
58
+ imp.original_file_url ?? `/api/v3/admin/imports/${imp.id}/download`,
59
+ imp.original_filename ?? 'import.csv',
60
+ ),
61
+ })
62
+ }
@@ -0,0 +1,99 @@
1
+ import { SpreeError } from '@spree/admin-sdk'
2
+ import {
3
+ type QueryKey,
4
+ type UseMutationOptions,
5
+ useMutation,
6
+ useQueryClient,
7
+ } from '@tanstack/react-query'
8
+ import { toast } from 'sonner'
9
+ import { i18n } from '../lib/i18n'
10
+ import { withStoreScope } from '../lib/query-keys'
11
+ import { useStore } from '../providers/store-provider'
12
+
13
+ interface UseResourceMutationOptions<TData, TError, TVariables>
14
+ extends Omit<UseMutationOptions<TData, TError, TVariables>, 'onSuccess' | 'onError'> {
15
+ /**
16
+ * Query keys to invalidate after success. Pass logical keys without
17
+ * storeId (e.g. `[['channels'], ['channels', id]]`) — the hook injects the
18
+ * current storeId at position 1 automatically so invalidation matches the
19
+ * store-scoped keys used by `ResourceTable` and other queries.
20
+ *
21
+ * Mutations that change setup-task state (see `Spree.store_setup_tasks` on
22
+ * the backend) must also invalidate `[STORE_QUERY_RESOURCE]` so the Getting
23
+ * Started checklist and the nav progress badge stay live.
24
+ */
25
+ invalidate?: QueryKey[]
26
+ /** Toast on success. Pass `false` to disable. Default `'Saved'`. */
27
+ successMessage?: string | false
28
+ /**
29
+ * Toast on error. Pass `false` to disable. Default `'Something went wrong'`.
30
+ *
31
+ * 422 validation errors never toast — callers that surface them inline via
32
+ * `mapSpreeErrorsToForm` would otherwise show the same problem twice. The
33
+ * toast is reserved for failures the form can't render (network, 5xx,
34
+ * auth, gateway).
35
+ */
36
+ errorMessage?: string | false
37
+ /** Forwarded onSuccess callback. Runs after invalidation + toast. */
38
+ onSuccess?: UseMutationOptions<TData, TError, TVariables>['onSuccess']
39
+ /** Forwarded onError callback. Runs after error toast. */
40
+ onError?: UseMutationOptions<TData, TError, TVariables>['onError']
41
+ }
42
+
43
+ /**
44
+ * Wrapper around `useMutation` that bundles the two patterns every resource
45
+ * mutation needs: query invalidation and success/error toasts.
46
+ *
47
+ * Replaces the per-page `try/catch + toast.success/toast.error` boilerplate.
48
+ *
49
+ * ```ts
50
+ * const updateProduct = useResourceMutation({
51
+ * mutationFn: (params) => adminClient.products.update(id, params),
52
+ * invalidate: [productQueryKey(id), productsQueryKey],
53
+ * successMessage: 'Product saved',
54
+ * errorMessage: 'Failed to save product',
55
+ * })
56
+ * ```
57
+ */
58
+ export function useResourceMutation<TData = unknown, TError = Error, TVariables = void>(
59
+ options: UseResourceMutationOptions<TData, TError, TVariables>,
60
+ ) {
61
+ const queryClient = useQueryClient()
62
+ const { storeId } = useStore()
63
+ const {
64
+ invalidate,
65
+ successMessage = i18n.t('admin.messages.saved'),
66
+ errorMessage = i18n.t('admin.errors.generic'),
67
+ onSuccess,
68
+ onError,
69
+ ...rest
70
+ } = options
71
+
72
+ return useMutation<TData, TError, TVariables>({
73
+ ...rest,
74
+ onSuccess: (data, variables, onMutateResult, ctx) => {
75
+ if (invalidate) {
76
+ for (const key of invalidate) {
77
+ queryClient.invalidateQueries({ queryKey: withStoreScope(key, storeId) })
78
+ }
79
+ }
80
+ if (successMessage !== false) {
81
+ toast.success(successMessage)
82
+ }
83
+ return onSuccess?.(data, variables, onMutateResult, ctx)
84
+ },
85
+ onError: (error, variables, onMutateResult, ctx) => {
86
+ if (errorMessage !== false && !isValidationError(error)) {
87
+ toast.error(errorMessage)
88
+ }
89
+ return onError?.(error, variables, onMutateResult, ctx)
90
+ },
91
+ })
92
+ }
93
+
94
+ // 422 with `details` means the model rejected the payload (e.g., "Code can't
95
+ // be blank"). The form renders these next to the offending input via
96
+ // `mapSpreeErrorsToForm`, so toasting the same thing would just be noise.
97
+ function isValidationError(error: unknown): boolean {
98
+ return error instanceof SpreeError && error.status === 422
99
+ }
@@ -0,0 +1,55 @@
1
+ import { useTranslation } from 'react-i18next'
2
+ import { toast } from 'sonner'
3
+ import { adminClient } from '../client'
4
+ import { i18n, markGenuineLocaleChoice, switchLocale } from '../lib/i18n'
5
+ import { useAuth } from './use-auth'
6
+
7
+ /**
8
+ * Returns a function that switches the admin UI language and makes it stick.
9
+ *
10
+ * Switching the UI language is a three-step operation that every entry point
11
+ * (top-bar switcher, profile form, store settings) must perform identically:
12
+ *
13
+ * 1. Persist `selected_locale` to the ACCOUNT (`PATCH /me`) — not just
14
+ * localStorage. The auth provider treats the account's `selected_locale`
15
+ * as the cross-device source of truth and reverts a localStorage-only
16
+ * switch on the next session bootstrap.
17
+ * 2. Mirror the change into the auth context so the in-memory user (top-bar,
18
+ * etc.) reflects it immediately instead of waiting for the next refresh.
19
+ * 3. `switchLocale` persists the choice and reloads so every module-load
20
+ * `i18n.t(...)` label re-resolves in the new language.
21
+ *
22
+ * A failed PATCH leaves localStorage and the page untouched (no desync) and
23
+ * surfaces an error toast. Fully a no-op only when the account ALREADY records
24
+ * `code` and the UI is already displaying it — otherwise the account is brought
25
+ * in sync even if the UI happens to match (e.g. the language was applied via the
26
+ * store-default fallback, which writes localStorage but leaves the account
27
+ * `selected_locale` null), and the page reloads only when the displayed language
28
+ * actually changes.
29
+ *
30
+ * @returns an async `switchAdminLocale(code)` performing the full sequence
31
+ */
32
+ export function useSwitchAdminLocale() {
33
+ const { t } = useTranslation()
34
+ const { user, updateUser } = useAuth()
35
+
36
+ return async (code: string): Promise<void> => {
37
+ // Already adopted on the account AND on screen — nothing to do.
38
+ if (code === user?.selected_locale && code === i18n.language) return
39
+ try {
40
+ const { user: updated } = await adminClient.me.update({ selected_locale: code })
41
+ updateUser(updated)
42
+ if (code !== i18n.language) {
43
+ // Displayed language changes — persist the genuine choice + reload.
44
+ switchLocale(code)
45
+ } else {
46
+ // Already on screen: no reload, but still record it as a GENUINE choice
47
+ // (clearing any store-default auto-marker) so a later store switch can't
48
+ // override what the admin explicitly picked.
49
+ markGenuineLocaleChoice(code)
50
+ }
51
+ } catch {
52
+ toast.error(t('admin.account.language.update_failed'))
53
+ }
54
+ }
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,97 @@
1
+ /// <reference path="./virtual.d.ts" />
2
+ // @spree/dashboard-core — Spree Dashboard framework.
3
+ //
4
+ // The extension API for plugin authors. Consumers import flat from this barrel:
5
+ //
6
+ // import { useAuth, defineTable, registerSlot, nav } from '@spree/dashboard-core'
7
+ //
8
+ // Or use the focused entry points:
9
+ //
10
+ // import { defineDashboardPlugin } from '@spree/dashboard-core/plugin'
11
+ // import { AuthProvider } from '@spree/dashboard-core/providers/auth-provider'
12
+ // import { useResourceMutation } from '@spree/dashboard-core/hooks/use-resource-mutation'
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Admin SDK client (Vite-aware singleton; reads VITE_SPREE_API_URL at build)
16
+ // ---------------------------------------------------------------------------
17
+ export { adminClient } from './client'
18
+ export * from './components/address-form-dialog'
19
+ export * from './components/app-sidebar'
20
+ export * from './components/bulk-action-bar'
21
+ export * from './components/can'
22
+ export * from './components/country-combobox'
23
+ export * from './components/country-state-fields'
24
+ export * from './components/currency-select'
25
+ export * from './components/export-button'
26
+ export * from './components/file-upload-field'
27
+ export * from './components/image-upload-field'
28
+ export * from './components/import-button'
29
+ export * from './components/locale-select'
30
+ export * from './components/market-combobox'
31
+ export * from './components/nav-main'
32
+ export * from './components/page-header'
33
+ export * from './components/page-tabs'
34
+ export * from './components/preferences-form'
35
+ export * from './components/resource-combobox'
36
+ export * from './components/resource-multi-autocomplete'
37
+ export * from './components/resource-picker-sheet'
38
+ export * from './components/resource-table'
39
+ export * from './components/settings-sidebar'
40
+ export * from './components/slot'
41
+ export * from './components/store-date-picker'
42
+ export * from './components/store-switcher'
43
+ export * from './components/table-toolbar'
44
+ export * from './components/tag-combobox'
45
+ export * from './components/top-bar'
46
+ // ---------------------------------------------------------------------------
47
+ // Infra hooks
48
+ // ---------------------------------------------------------------------------
49
+ export * from './hooks/use-auth'
50
+ export * from './hooks/use-command-palette'
51
+ export * from './hooks/use-countries'
52
+ export * from './hooks/use-custom-fields'
53
+ export * from './hooks/use-direct-upload'
54
+ export * from './hooks/use-display-name'
55
+ export * from './hooks/use-export'
56
+ export * from './hooks/use-global-search'
57
+ export * from './hooks/use-host-form'
58
+ export * from './hooks/use-import'
59
+ export * from './hooks/use-resource-mutation'
60
+ export * from './hooks/use-switch-admin-locale'
61
+ // ---------------------------------------------------------------------------
62
+ // Registries — pluggable extension points (nav, route, slot, table,
63
+ // settings-nav, form fields, custom field components)
64
+ // ---------------------------------------------------------------------------
65
+ export * from './lib/custom-field-components'
66
+ // ---------------------------------------------------------------------------
67
+ // Helpers
68
+ // ---------------------------------------------------------------------------
69
+ export * from './lib/filters-to-ransack'
70
+ export * from './lib/form-errors'
71
+ export * from './lib/form-fields-registry'
72
+ export * from './lib/form-mappers'
73
+ export * from './lib/formatters'
74
+ // i18n side-effect import bootstraps i18next + React adapter on first import.
75
+ // Consumers that need the singleton (e.g. plugin authors calling
76
+ // `i18n.addResourceBundle`) get it via the named re-export.
77
+ export * from './lib/i18n'
78
+ export * from './lib/nav-registry'
79
+ export * from './lib/permissions'
80
+ export * from './lib/query-client'
81
+ export * from './lib/query-keys'
82
+ export * from './lib/route-registry'
83
+ export * from './lib/search-registry'
84
+ export * from './lib/settings-nav-registry'
85
+ export * from './lib/slot-registry'
86
+ export * from './lib/table-registry'
87
+ export { ensureTimestampColumns } from './lib/timestamp-columns'
88
+ // ---------------------------------------------------------------------------
89
+ // Plugin facade — re-exported for convenience; same API as `/plugin` subpath
90
+ // ---------------------------------------------------------------------------
91
+ export * from './plugin'
92
+ // ---------------------------------------------------------------------------
93
+ // Providers — must be mounted by the consuming app shell
94
+ // ---------------------------------------------------------------------------
95
+ export * from './providers/auth-provider'
96
+ export * from './providers/permission-provider'
97
+ export * from './providers/store-provider'
@@ -0,0 +1,56 @@
1
+ import type { CustomFieldDefinition } from '@spree/admin-sdk'
2
+ import type { ComponentType } from 'react'
3
+
4
+ /**
5
+ * Props a custom field input component receives from the custom-fields card.
6
+ * The component is a controlled input: render `value`, call `onChange` with
7
+ * the next value — hydration and persistence are the card's concern (the
8
+ * page form's Save for products/categories, the card's own Save elsewhere).
9
+ */
10
+ export interface CustomFieldComponentProps {
11
+ /** DOM id — wire it to your input for the card's `<label htmlFor>`. */
12
+ id: string
13
+ ariaLabel: string
14
+ value: unknown
15
+ onChange: (value: unknown) => void
16
+ /** The full definition (field_type, config, namespace, key, …). */
17
+ definition: CustomFieldDefinition
18
+ }
19
+
20
+ export type CustomFieldComponent = ComponentType<CustomFieldComponentProps>
21
+
22
+ const components = new Map<string, CustomFieldComponent>()
23
+
24
+ /**
25
+ * Replace the input widget for a specific custom field definition, keyed by
26
+ * its `namespace.key` identity (e.g. `'specs.color'`). When no component is
27
+ * registered for a definition, the default widget for its `field_type`
28
+ * renders — registering is purely additive.
29
+ *
30
+ * Keying by the definition's own identity (rather than a separate component
31
+ * ID the definition would reference) means there's no stringly-typed
32
+ * indirection to typo: the plugin that ships the definition registers the
33
+ * component for it in the same breath.
34
+ */
35
+ export const customFieldComponents = {
36
+ register(namespaceDotKey: string, component: CustomFieldComponent): void {
37
+ if (components.has(namespaceDotKey)) {
38
+ throw new Error(`A custom field component is already registered for "${namespaceDotKey}".`)
39
+ }
40
+ components.set(namespaceDotKey, component)
41
+ },
42
+
43
+ get(namespaceDotKey: string): CustomFieldComponent | undefined {
44
+ return components.get(namespaceDotKey)
45
+ },
46
+
47
+ /** Remove a registered component. No-op when absent. */
48
+ remove(namespaceDotKey: string): void {
49
+ components.delete(namespaceDotKey)
50
+ },
51
+ }
52
+
53
+ /** Test-only: clear the registry. Not exported from the package index. */
54
+ export function __resetCustomFieldComponents(): void {
55
+ components.clear()
56
+ }
@@ -0,0 +1,49 @@
1
+ const API_BASE_URL = import.meta.env.VITE_SPREE_API_URL || ''
2
+
3
+ /**
4
+ * Resolve `urlOrPath` (a path or absolute URL) against the API base and
5
+ * report whether it targets the trusted API origin. Origins are computed
6
+ * lazily so importing this module never touches `window` (node test envs).
7
+ */
8
+ function resolveApiUrl(urlOrPath: string): { url: string; sameOrigin: boolean } {
9
+ const url = /^https?:\/\//.test(urlOrPath) ? urlOrPath : `${API_BASE_URL}${urlOrPath}`
10
+ const apiOrigin = API_BASE_URL ? new URL(API_BASE_URL).origin : window.location.origin
11
+ const sameOrigin = new URL(url, window.location.origin).origin === apiOrigin
12
+ return { url, sameOrigin }
13
+ }
14
+
15
+ /**
16
+ * Authed fetch → Blob URL download for JWT-protected endpoints — a top-level
17
+ * navigation cannot carry the in-memory token. Only requests to the API
18
+ * origin get the `Authorization` header and cookies, so the token can't leak
19
+ * to a third-party host (e.g. a URL echoed back by the server). The filename
20
+ * comes from `Content-Disposition` when present, else `fallbackName`.
21
+ */
22
+ export async function downloadFromApi(
23
+ token: string | null,
24
+ urlOrPath: string,
25
+ fallbackName: string,
26
+ ): Promise<void> {
27
+ const { url, sameOrigin } = resolveApiUrl(urlOrPath)
28
+ const headers: Record<string, string> = {}
29
+ if (sameOrigin && token) headers.Authorization = `Bearer ${token}`
30
+
31
+ const response = await fetch(url, {
32
+ headers,
33
+ credentials: sameOrigin ? 'include' : 'omit',
34
+ })
35
+ if (!response.ok) throw new Error(`Download failed: ${response.status}`)
36
+
37
+ const disposition = response.headers.get('Content-Disposition')
38
+ const filename = disposition?.match(/filename="?([^";]+)"?/)?.[1] ?? fallbackName
39
+
40
+ const blob = await response.blob()
41
+ const objectUrl = URL.createObjectURL(blob)
42
+ const anchor = document.createElement('a')
43
+ anchor.href = objectUrl
44
+ anchor.download = filename
45
+ document.body.appendChild(anchor)
46
+ anchor.click()
47
+ anchor.remove()
48
+ URL.revokeObjectURL(objectUrl)
49
+ }
@@ -0,0 +1,47 @@
1
+ import { type ColumnDef, type FilterRule, parseFilterIds } from './table-registry'
2
+
3
+ /**
4
+ * Convert toolbar `FilterRule[]` state into a flat Ransack predicate hash:
5
+ *
6
+ * [{ field: 'name', operator: 'cont', value: 'shirt' }]
7
+ *
8
+ * becomes
9
+ *
10
+ * { name_cont: 'shirt' }
11
+ *
12
+ * Columns can override the predicate target via `ransackAttribute` (e.g.
13
+ * the `sku` column on products filters through `master_sku_cont`).
14
+ *
15
+ * The output shape is what both the list endpoints (after
16
+ * `transformListParams` wraps each key in `q[...]`) and the export endpoint
17
+ * (which stores the hash as `search_params`) expect.
18
+ */
19
+ // Array-valued operators get a `[]` suffix so `transformListParams` emits
20
+ // `q[xxx_in][]=...` for each item. The chip stores the IDs as a CSV string
21
+ // in `FilterRule.value` so the URL serializer (which already JSON-stringifies
22
+ // FilterRule[]) stays simple.
23
+ const ARRAY_OPERATORS = new Set(['in', 'not_in'])
24
+
25
+ export function filtersToRansack(
26
+ filters: FilterRule[],
27
+ columns: ColumnDef[],
28
+ ): Record<string, string | string[]> {
29
+ const out: Record<string, string | string[]> = {}
30
+ for (const filter of filters) {
31
+ const col = columns.find((c) => c.key === filter.field)
32
+ // `filterType: 'tags'` rides on the polymorphic `tags` association exposed
33
+ // via `acts_as_taggable_on`. Predicates target the join's `name` column
34
+ // (`tags_name_in`), not the column key (`tags_in`), so default the
35
+ // ransack alias here when one isn't explicitly set.
36
+ const fallback = col?.filterType === 'tags' ? 'tags_name' : filter.field
37
+ const ransackKey = col?.ransackAttribute ?? fallback
38
+ const key = `${ransackKey}_${filter.operator}`
39
+ if (ARRAY_OPERATORS.has(filter.operator)) {
40
+ const ids = parseFilterIds(filter.value)
41
+ if (ids.length > 0) out[key] = ids
42
+ } else {
43
+ out[key] = filter.value
44
+ }
45
+ }
46
+ return out
47
+ }