@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.
- package/LICENSE +21 -0
- package/README.md +418 -0
- package/package.json +84 -0
- package/src/client.ts +25 -0
- package/src/components/address-form-dialog.tsx +376 -0
- package/src/components/app-sidebar.tsx +70 -0
- package/src/components/bulk-action-bar.tsx +504 -0
- package/src/components/can.tsx +30 -0
- package/src/components/country-combobox.tsx +238 -0
- package/src/components/country-state-fields.tsx +86 -0
- package/src/components/currency-select.tsx +181 -0
- package/src/components/export-button.tsx +156 -0
- package/src/components/file-upload-field.tsx +265 -0
- package/src/components/image-upload-field.tsx +53 -0
- package/src/components/import-button.tsx +202 -0
- package/src/components/locale-select.tsx +268 -0
- package/src/components/market-combobox.tsx +89 -0
- package/src/components/nav-main.tsx +186 -0
- package/src/components/page-header.tsx +286 -0
- package/src/components/page-tabs.tsx +78 -0
- package/src/components/preferences-form.tsx +404 -0
- package/src/components/resource-combobox.tsx +126 -0
- package/src/components/resource-multi-autocomplete.tsx +176 -0
- package/src/components/resource-picker-sheet.tsx +235 -0
- package/src/components/resource-table.tsx +652 -0
- package/src/components/settings-sidebar.tsx +139 -0
- package/src/components/slot.tsx +50 -0
- package/src/components/store-date-picker.tsx +18 -0
- package/src/components/store-switcher.tsx +70 -0
- package/src/components/table-toolbar.tsx +818 -0
- package/src/components/tag-combobox.tsx +99 -0
- package/src/components/top-bar.tsx +210 -0
- package/src/hooks/use-auth.ts +10 -0
- package/src/hooks/use-command-palette.tsx +34 -0
- package/src/hooks/use-countries.ts +15 -0
- package/src/hooks/use-custom-fields.ts +142 -0
- package/src/hooks/use-direct-upload.ts +104 -0
- package/src/hooks/use-display-name.ts +42 -0
- package/src/hooks/use-export.ts +75 -0
- package/src/hooks/use-global-search.ts +79 -0
- package/src/hooks/use-host-form.ts +34 -0
- package/src/hooks/use-import.ts +62 -0
- package/src/hooks/use-resource-mutation.ts +99 -0
- package/src/hooks/use-switch-admin-locale.ts +55 -0
- package/src/index.ts +97 -0
- package/src/lib/custom-field-components.ts +56 -0
- package/src/lib/download.ts +49 -0
- package/src/lib/filters-to-ransack.ts +47 -0
- package/src/lib/form-errors.ts +103 -0
- package/src/lib/form-fields-registry.ts +102 -0
- package/src/lib/form-mappers.ts +30 -0
- package/src/lib/formatters.ts +19 -0
- package/src/lib/i18n.ts +210 -0
- package/src/lib/nav-registry.ts +224 -0
- package/src/lib/permissions.ts +55 -0
- package/src/lib/query-client.ts +11 -0
- package/src/lib/query-keys.ts +69 -0
- package/src/lib/route-registry.ts +158 -0
- package/src/lib/search-registry.ts +129 -0
- package/src/lib/settings-nav-registry.ts +158 -0
- package/src/lib/slot-registry.ts +116 -0
- package/src/lib/table-registry.ts +274 -0
- package/src/lib/timestamp-columns.tsx +58 -0
- package/src/locales/ar.json +629 -0
- package/src/locales/de.json +629 -0
- package/src/locales/en.json +629 -0
- package/src/locales/fr.json +629 -0
- package/src/locales/pl.json +629 -0
- package/src/locales/zh-CN.json +629 -0
- package/src/plugin.ts +222 -0
- package/src/providers/auth-provider.tsx +188 -0
- package/src/providers/permission-provider.tsx +118 -0
- package/src/providers/store-provider.tsx +111 -0
- package/src/virtual.d.ts +5 -0
- package/src/vite/discover.ts +170 -0
- 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
|
+
}
|