@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,99 @@
1
+ import {
2
+ Combobox,
3
+ ComboboxChip,
4
+ ComboboxChips,
5
+ ComboboxChipsInput,
6
+ ComboboxContent,
7
+ ComboboxEmpty,
8
+ ComboboxItem,
9
+ ComboboxList,
10
+ ComboboxValue,
11
+ useComboboxAnchor,
12
+ } from '@spree/dashboard-ui'
13
+ import { useQuery } from '@tanstack/react-query'
14
+ import { useMemo, useState } from 'react'
15
+ import { useTranslation } from 'react-i18next'
16
+ import { adminClient } from '../client'
17
+ import { useAuth } from '../hooks/use-auth'
18
+ import type { TaggableType } from '../lib/table-registry'
19
+
20
+ export type { TaggableType }
21
+
22
+ export function TagCombobox({
23
+ taggableType,
24
+ value,
25
+ onChange,
26
+ placeholder,
27
+ }: {
28
+ taggableType: TaggableType
29
+ value: string[]
30
+ onChange: (value: string[]) => void
31
+ placeholder?: string
32
+ }) {
33
+ const { t } = useTranslation()
34
+ const resolvedPlaceholder = placeholder ?? t('admin.components.tag_combobox.placeholder')
35
+ const anchorRef = useComboboxAnchor()
36
+ const [inputValue, setInputValue] = useState('')
37
+ const { isAuthenticated } = useAuth()
38
+
39
+ const { data } = useQuery({
40
+ queryKey: ['admin-tags', taggableType],
41
+ queryFn: () => adminClient.tags.list({ taggable_type: taggableType }),
42
+ enabled: isAuthenticated,
43
+ staleTime: 60_000,
44
+ })
45
+
46
+ const existingTags = data?.data?.map((t) => t.name) ?? []
47
+
48
+ // Append the typed value as a "Create" candidate so the user can confirm new tags
49
+ // via the dropdown (in addition to the Enter shortcut).
50
+ const items = useMemo(() => {
51
+ const set = new Set([...existingTags, ...value])
52
+ const trimmed = inputValue.trim()
53
+ if (trimmed) set.add(trimmed)
54
+ return Array.from(set)
55
+ }, [existingTags, value, inputValue])
56
+
57
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
58
+ if (e.key === 'Enter' && inputValue.trim()) {
59
+ e.preventDefault()
60
+ const tag = inputValue.trim()
61
+ if (!value.includes(tag)) onChange([...value, tag])
62
+ setInputValue('')
63
+ }
64
+ }
65
+
66
+ return (
67
+ <Combobox multiple items={items} value={value} onValueChange={onChange}>
68
+ <ComboboxChips ref={anchorRef}>
69
+ <ComboboxValue>
70
+ {(selectedValues: string[]) =>
71
+ selectedValues.map((tag) => <ComboboxChip key={tag}>{tag}</ComboboxChip>)
72
+ }
73
+ </ComboboxValue>
74
+ <ComboboxChipsInput
75
+ placeholder={resolvedPlaceholder}
76
+ value={inputValue}
77
+ onChange={(e) => setInputValue((e.target as HTMLInputElement).value)}
78
+ onKeyDown={handleKeyDown}
79
+ />
80
+ </ComboboxChips>
81
+ <ComboboxContent anchor={anchorRef}>
82
+ <ComboboxEmpty>{t('admin.components.tag_combobox.empty_hint')}</ComboboxEmpty>
83
+ <ComboboxList>
84
+ {(tag: string) => {
85
+ const isSelected = value.includes(tag)
86
+ const isExisting = existingTags.includes(tag)
87
+ return (
88
+ <ComboboxItem key={tag} value={tag}>
89
+ {isExisting || isSelected
90
+ ? tag
91
+ : t('admin.components.tag_combobox.create_option', { tag })}
92
+ </ComboboxItem>
93
+ )
94
+ }}
95
+ </ComboboxList>
96
+ </ComboboxContent>
97
+ </Combobox>
98
+ )
99
+ }
@@ -0,0 +1,210 @@
1
+ import {
2
+ Avatar,
3
+ AvatarFallback,
4
+ AvatarImage,
5
+ Button,
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuItem,
9
+ DropdownMenuSeparator,
10
+ DropdownMenuTrigger,
11
+ LanguageMenuItems,
12
+ SidebarTrigger,
13
+ ThemeMenuItems,
14
+ } from '@spree/dashboard-ui'
15
+ import { Link } from '@tanstack/react-router'
16
+ import {
17
+ BookOpenIcon,
18
+ ExternalLinkIcon,
19
+ LogOutIcon,
20
+ MailIcon,
21
+ MessageCircleIcon,
22
+ SearchIcon,
23
+ UserIcon,
24
+ } from 'lucide-react'
25
+ import { useTranslation } from 'react-i18next'
26
+ import { useAuth } from '../hooks/use-auth'
27
+ import { useCommandPalette } from '../hooks/use-command-palette'
28
+ import { useSwitchAdminLocale } from '../hooks/use-switch-admin-locale'
29
+ import { getInitials } from '../lib/formatters'
30
+ import { i18n } from '../lib/i18n'
31
+ import { useStore } from '../providers/store-provider'
32
+
33
+ const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform ?? '')
34
+
35
+ /**
36
+ * Top-bar shell for the admin SPA. Three-slot layout:
37
+ * [☰ sidebar toggle] [global search] [view store · user menu]
38
+ *
39
+ * The search trigger opens the global ⌘K command palette (provider mounted at
40
+ * the layout root). The keyboard shortcut is registered in the provider, not
41
+ * here, so it works regardless of focus location.
42
+ */
43
+ /**
44
+ * @param uiLocales Admin UI languages for the in-menu switcher. The app owns
45
+ * which locale bundles ship (see the dashboard's `getAvailableUiLocales`) and
46
+ * injects them here, so this core component stays free of bundle knowledge.
47
+ */
48
+ export function TopBar({
49
+ uiLocales = [],
50
+ }: {
51
+ uiLocales?: ReadonlyArray<{ code: string; name: string }>
52
+ }) {
53
+ return (
54
+ <header className="sticky top-0 z-40 flex h-header-height shrink-0 items-center gap-3 bg-background/90 px-4 border-b border-border/50 backdrop-blur supports-[backdrop-filter]:bg-background/75">
55
+ <SidebarTrigger className="-ml-1 h-8 w-8" />
56
+
57
+ <div className="flex flex-1 justify-center">
58
+ <SearchTrigger />
59
+ </div>
60
+
61
+ <div className="flex items-center gap-2">
62
+ <ViewStoreLink />
63
+ <TopBarUser uiLocales={uiLocales} />
64
+ </div>
65
+ </header>
66
+ )
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Search trigger — opens the global command palette
71
+ // ---------------------------------------------------------------------------
72
+
73
+ function SearchTrigger() {
74
+ const { t } = useTranslation()
75
+ const { setOpen } = useCommandPalette()
76
+
77
+ return (
78
+ <button
79
+ type="button"
80
+ onClick={() => setOpen(true)}
81
+ className="flex w-full max-w-md items-center gap-2 rounded-lg border border-border bg-muted/40 px-2 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted"
82
+ >
83
+ <SearchIcon className="size-4" />
84
+ <span className="flex-1 text-left">{t('admin.components.command_palette.placeholder')}</span>
85
+ <kbd className="hidden rounded border bg-background px-1.5 py-0.5 font-mono text-xs sm:inline-flex">
86
+ {IS_MAC ? '⌘K' : 'Ctrl+K'}
87
+ </kbd>
88
+ </button>
89
+ )
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // View Store link
94
+ // ---------------------------------------------------------------------------
95
+
96
+ function ViewStoreLink() {
97
+ const { t } = useTranslation()
98
+ const { store } = useStore()
99
+ if (!store?.url) return null
100
+
101
+ // Best-effort: prefix with https if the URL is just a hostname.
102
+ const href = /^https?:\/\//.test(store.url) ? store.url : `https://${store.url}`
103
+
104
+ return (
105
+ <Button asChild variant="ghost" size="sm">
106
+ <a href={href} target="_blank" rel="noreferrer">
107
+ <ExternalLinkIcon className="size-4" />
108
+ <span className="hidden sm:inline">{t('admin.account.view_store')}</span>
109
+ </a>
110
+ </Button>
111
+ )
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // User menu
116
+ // ---------------------------------------------------------------------------
117
+
118
+ function TopBarUser({ uiLocales }: { uiLocales: ReadonlyArray<{ code: string; name: string }> }) {
119
+ const { t } = useTranslation()
120
+ const { user, logout } = useAuth()
121
+ const { store } = useStore()
122
+ const switchAdminLocale = useSwitchAdminLocale()
123
+ if (!user) return null
124
+
125
+ const initials = getInitials(user.full_name, user.email)
126
+
127
+ // Switching the admin UI language persists to the account, mirrors it into
128
+ // the auth context, and reloads — see useSwitchAdminLocale for why all three
129
+ // steps are required.
130
+ const handleSelectLocale = (code: string) => {
131
+ void switchAdminLocale(code)
132
+ }
133
+
134
+ return (
135
+ <DropdownMenu>
136
+ <DropdownMenuTrigger asChild>
137
+ <button
138
+ type="button"
139
+ aria-label={t('admin.a11y.user_menu')}
140
+ className="flex items-center gap-2 rounded-lg p-1 transition-colors hover:bg-accent"
141
+ >
142
+ <Avatar className="size-7">
143
+ {user.avatar_url && <AvatarImage src={user.avatar_url} alt="" />}
144
+ <AvatarFallback className="bg-primary text-xs text-primary-foreground dark:bg-accent dark:text-foreground">
145
+ {initials}
146
+ </AvatarFallback>
147
+ </Avatar>
148
+ </button>
149
+ </DropdownMenuTrigger>
150
+ <DropdownMenuContent align="end" className="w-56" sideOffset={8}>
151
+ <div className="flex items-center gap-2 p-1.5">
152
+ <Avatar className="size-8">
153
+ {user.avatar_url && <AvatarImage src={user.avatar_url} alt="" />}
154
+ <AvatarFallback className="bg-primary text-xs text-primary-foreground dark:bg-accent dark:text-foreground">
155
+ {initials}
156
+ </AvatarFallback>
157
+ </Avatar>
158
+ <div className="grid min-w-0 flex-1 text-sm leading-tight">
159
+ <span className="truncate font-medium text-foreground">
160
+ {user.full_name || user.email}
161
+ </span>
162
+ {user.full_name && (
163
+ <span className="truncate text-xs text-muted-foreground">{user.email}</span>
164
+ )}
165
+ </div>
166
+ </div>
167
+ <DropdownMenuSeparator />
168
+ <ThemeMenuItems />
169
+ {/* Compact nested submenu; self-hides when < 2 languages are installed. */}
170
+ <LanguageMenuItems
171
+ label={t('admin.account.language.label')}
172
+ locales={uiLocales}
173
+ value={i18n.language}
174
+ onSelect={handleSelectLocale}
175
+ />
176
+ {uiLocales.length >= 2 && <DropdownMenuSeparator />}
177
+ {store && (
178
+ <DropdownMenuItem asChild>
179
+ <Link
180
+ to="/$storeId/settings/profile"
181
+ params={{ storeId: store.id }}
182
+ className="no-underline"
183
+ >
184
+ <UserIcon className="size-4" />
185
+ {t('admin.account.edit_profile')}
186
+ </Link>
187
+ </DropdownMenuItem>
188
+ )}
189
+ <DropdownMenuSeparator />
190
+ <DropdownMenuItem>
191
+ <BookOpenIcon className="size-4" />
192
+ {t('admin.account.documentation')}
193
+ </DropdownMenuItem>
194
+ <DropdownMenuItem>
195
+ <MessageCircleIcon className="size-4" />
196
+ {t('admin.account.community')}
197
+ </DropdownMenuItem>
198
+ <DropdownMenuItem>
199
+ <MailIcon className="size-4" />
200
+ {t('admin.account.contact_support')}
201
+ </DropdownMenuItem>
202
+ <DropdownMenuSeparator />
203
+ <DropdownMenuItem onClick={logout} className="text-destructive focus:text-destructive">
204
+ <LogOutIcon className="size-4" />
205
+ {t('admin.account.log_out')}
206
+ </DropdownMenuItem>
207
+ </DropdownMenuContent>
208
+ </DropdownMenu>
209
+ )
210
+ }
@@ -0,0 +1,10 @@
1
+ import { useContext } from 'react'
2
+ import { AuthContext } from '../providers/auth-provider'
3
+
4
+ export function useAuth() {
5
+ const context = useContext(AuthContext)
6
+ if (!context) {
7
+ throw new Error('useAuth must be used within an AuthProvider')
8
+ }
9
+ return context
10
+ }
@@ -0,0 +1,34 @@
1
+ import { useHotkey } from '@tanstack/react-hotkeys'
2
+ import { createContext, type ReactNode, useContext, useMemo, useState } from 'react'
3
+
4
+ interface CommandPaletteState {
5
+ open: boolean
6
+ setOpen: (open: boolean) => void
7
+ }
8
+
9
+ const CommandPaletteContext = createContext<CommandPaletteState | null>(null)
10
+
11
+ /**
12
+ * Mounts at the app shell so the top-bar trigger, the global ⌘K shortcut, and
13
+ * the palette dialog itself share a single source of truth without
14
+ * prop-drilling through layouts.
15
+ */
16
+ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
17
+ const [open, setOpen] = useState(false)
18
+
19
+ useHotkey('Mod+K', () => {
20
+ setOpen((prev) => !prev)
21
+ })
22
+
23
+ const value = useMemo<CommandPaletteState>(() => ({ open, setOpen }), [open])
24
+
25
+ return <CommandPaletteContext.Provider value={value}>{children}</CommandPaletteContext.Provider>
26
+ }
27
+
28
+ export function useCommandPalette(): CommandPaletteState {
29
+ const ctx = useContext(CommandPaletteContext)
30
+ if (!ctx) {
31
+ throw new Error('useCommandPalette must be used within a CommandPaletteProvider')
32
+ }
33
+ return ctx
34
+ }
@@ -0,0 +1,15 @@
1
+ import type { Country } from '@spree/admin-sdk'
2
+ import { useQuery } from '@tanstack/react-query'
3
+ import { adminClient } from '../client'
4
+
5
+ export function useCountries() {
6
+ const { data, isLoading } = useQuery({
7
+ queryKey: ['countries'],
8
+ queryFn: () => adminClient.countries.list({ expand: ['states'] }),
9
+ staleTime: 1000 * 60 * 30, // 30 minutes — countries rarely change
10
+ })
11
+
12
+ const countries: Country[] = data?.data ?? []
13
+
14
+ return { countries, isLoading }
15
+ }
@@ -0,0 +1,142 @@
1
+ import type {
2
+ CustomFieldCreateParams,
3
+ CustomFieldDefinition,
4
+ CustomFieldDefinitionCreateParams,
5
+ CustomFieldDefinitionUpdateParams,
6
+ CustomFieldOwnerType,
7
+ CustomFieldUpdateParams,
8
+ } from '@spree/admin-sdk'
9
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
10
+ import { adminClient } from '../client'
11
+ import { i18n } from '../lib/i18n'
12
+ import { useResourceMutation } from './use-resource-mutation'
13
+
14
+ const valuesKey = (ownerType: CustomFieldOwnerType, ownerId: string) =>
15
+ ['custom-fields', ownerType, ownerId] as const
16
+
17
+ // Root key used to invalidate every definitions query at once. The
18
+ // per-resource and per-id keys below extend it so TanStack's prefix
19
+ // invalidation catches them.
20
+ export const customFieldDefinitionsRootKey = ['custom-field-definitions'] as const
21
+
22
+ const definitionsKey = (resourceType: string) =>
23
+ [...customFieldDefinitionsRootKey, resourceType] as const
24
+
25
+ const definitionByIdKey = (id: string) => [...customFieldDefinitionsRootKey, 'id', id] as const
26
+
27
+ export function useCustomFields(ownerType: CustomFieldOwnerType, ownerId: string) {
28
+ return useQuery({
29
+ queryKey: valuesKey(ownerType, ownerId),
30
+ queryFn: () => adminClient.customFields(ownerType, ownerId).list({ limit: 100 }),
31
+ enabled: !!ownerId,
32
+ })
33
+ }
34
+
35
+ export function useCustomFieldDefinitions(resourceType: string) {
36
+ return useQuery({
37
+ queryKey: definitionsKey(resourceType),
38
+ queryFn: () =>
39
+ adminClient.customFieldDefinitions.list({
40
+ limit: 100,
41
+ resource_type_eq: resourceType,
42
+ }),
43
+ enabled: !!resourceType,
44
+ })
45
+ }
46
+
47
+ export function useCreateCustomField(ownerType: CustomFieldOwnerType, ownerId: string) {
48
+ const queryClient = useQueryClient()
49
+ return useMutation({
50
+ mutationFn: (params: CustomFieldCreateParams) =>
51
+ adminClient.customFields(ownerType, ownerId).create(params),
52
+ onSuccess: () => {
53
+ queryClient.invalidateQueries({ queryKey: valuesKey(ownerType, ownerId) })
54
+ },
55
+ })
56
+ }
57
+
58
+ export function useUpdateCustomField(ownerType: CustomFieldOwnerType, ownerId: string) {
59
+ const queryClient = useQueryClient()
60
+ return useMutation({
61
+ mutationFn: ({ id, ...params }: { id: string } & CustomFieldUpdateParams) =>
62
+ adminClient.customFields(ownerType, ownerId).update(id, params),
63
+ onSuccess: () => {
64
+ queryClient.invalidateQueries({ queryKey: valuesKey(ownerType, ownerId) })
65
+ },
66
+ })
67
+ }
68
+
69
+ export function useDeleteCustomField(ownerType: CustomFieldOwnerType, ownerId: string) {
70
+ const queryClient = useQueryClient()
71
+ return useMutation({
72
+ mutationFn: (id: string) => adminClient.customFields(ownerType, ownerId).delete(id),
73
+ onSuccess: () => {
74
+ queryClient.invalidateQueries({ queryKey: valuesKey(ownerType, ownerId) })
75
+ },
76
+ })
77
+ }
78
+
79
+ export function useCustomFieldDefinition(id: string | undefined) {
80
+ return useQuery({
81
+ queryKey: id ? definitionByIdKey(id) : [...customFieldDefinitionsRootKey, 'id', 'noop'],
82
+ queryFn: () => adminClient.customFieldDefinitions.get(id as string),
83
+ enabled: !!id,
84
+ })
85
+ }
86
+
87
+ /**
88
+ * Settings-page variant — no `resourceType` argument. Invalidates the root
89
+ * key, which covers every per-resource and per-id definition query.
90
+ *
91
+ * Toast + 422 handling come for free via `useResourceMutation`: validation
92
+ * errors surface inline through `mapSpreeErrorsToForm`, everything else
93
+ * (network, 5xx) shows a toast.
94
+ */
95
+ export function useCreateCustomFieldDefinitionForSettings() {
96
+ return useResourceMutation<CustomFieldDefinition, Error, CustomFieldDefinitionCreateParams>({
97
+ mutationFn: (params) => adminClient.customFieldDefinitions.create(params),
98
+ invalidate: [customFieldDefinitionsRootKey],
99
+ successMessage: i18n.t('admin.custom_field_definitions.messages.created'),
100
+ errorMessage: i18n.t('admin.custom_field_definitions.errors.failed_to_create'),
101
+ })
102
+ }
103
+
104
+ export function useUpdateCustomFieldDefinitionForSettings(id: string) {
105
+ return useResourceMutation<CustomFieldDefinition, Error, CustomFieldDefinitionUpdateParams>({
106
+ mutationFn: (params) => adminClient.customFieldDefinitions.update(id, params),
107
+ invalidate: [customFieldDefinitionsRootKey, definitionByIdKey(id)],
108
+ successMessage: i18n.t('admin.custom_field_definitions.messages.updated'),
109
+ errorMessage: i18n.t('admin.custom_field_definitions.errors.failed_to_update'),
110
+ })
111
+ }
112
+
113
+ export function useDeleteCustomFieldDefinitionForSettings() {
114
+ const queryClient = useQueryClient()
115
+ return useResourceMutation<void, Error, string>({
116
+ mutationFn: (id) => adminClient.customFieldDefinitions.delete(id),
117
+ invalidate: [customFieldDefinitionsRootKey],
118
+ successMessage: i18n.t('admin.custom_field_definitions.messages.deleted'),
119
+ errorMessage: i18n.t('admin.custom_field_definitions.errors.failed_to_delete'),
120
+ onSuccess: (_data, id) => {
121
+ queryClient.removeQueries({ queryKey: definitionByIdKey(id) })
122
+ },
123
+ })
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Resource-scoped create — pins invalidation to a single `resourceType` so the
128
+ // inline custom-fields card's definitions query re-validates the moment a field
129
+ // is created in place (the create-definition sheet on a record detail page).
130
+ // ---------------------------------------------------------------------------
131
+
132
+ export function useCreateCustomFieldDefinition(resourceType: string) {
133
+ const queryClient = useQueryClient()
134
+ return useMutation({
135
+ mutationFn: (params: CustomFieldDefinitionCreateParams) =>
136
+ adminClient.customFieldDefinitions.create(params),
137
+ onSuccess: () => {
138
+ queryClient.invalidateQueries({ queryKey: definitionsKey(resourceType) })
139
+ queryClient.invalidateQueries({ queryKey: customFieldDefinitionsRootKey })
140
+ },
141
+ })
142
+ }
@@ -0,0 +1,104 @@
1
+ import { useMutation } from '@tanstack/react-query'
2
+ import SparkMD5 from 'spark-md5'
3
+ import { adminClient } from '../client'
4
+
5
+ function computeMD5Checksum(file: File): Promise<string> {
6
+ return new Promise((resolve, reject) => {
7
+ const chunkSize = 2097152 // 2MB chunks
8
+ const spark = new SparkMD5.ArrayBuffer()
9
+ const reader = new FileReader()
10
+ let currentChunk = 0
11
+ const chunks = Math.ceil(file.size / chunkSize)
12
+
13
+ reader.onload = (e) => {
14
+ spark.append(e.target!.result as ArrayBuffer)
15
+ currentChunk++
16
+
17
+ if (currentChunk < chunks) {
18
+ loadNext()
19
+ } else {
20
+ // Active Storage expects base64-encoded raw MD5 digest
21
+ resolve(btoa(spark.end(true)))
22
+ }
23
+ }
24
+
25
+ reader.onerror = () => reject(reader.error)
26
+
27
+ function loadNext() {
28
+ const start = currentChunk * chunkSize
29
+ const end = Math.min(start + chunkSize, file.size)
30
+ reader.readAsArrayBuffer(file.slice(start, end))
31
+ }
32
+
33
+ loadNext()
34
+ })
35
+ }
36
+
37
+ interface UploadResult {
38
+ signedId: string
39
+ previewUrl: string
40
+ }
41
+
42
+ function sameOriginIfRailsDisk(url: string): string {
43
+ try {
44
+ const parsed = new URL(url)
45
+ if (parsed.pathname.startsWith('/rails/active_storage/')) {
46
+ return parsed.pathname + parsed.search
47
+ }
48
+ return url
49
+ } catch {
50
+ return url
51
+ }
52
+ }
53
+
54
+ export function useDirectUpload() {
55
+ return useMutation({
56
+ mutationFn: async (file: File): Promise<UploadResult> => {
57
+ const checksum = await computeMD5Checksum(file)
58
+
59
+ // Step 1: Get presigned upload URL
60
+ let response: {
61
+ direct_upload: { url: string; headers: Record<string, string> }
62
+ signed_id: string
63
+ }
64
+ try {
65
+ response = await adminClient.directUploads.create({
66
+ blob: {
67
+ filename: file.name,
68
+ byte_size: file.size,
69
+ checksum,
70
+ content_type: file.type || 'application/octet-stream',
71
+ },
72
+ })
73
+ } catch (err) {
74
+ throw new Error(`Presign failed: ${err instanceof Error ? err.message : err}`)
75
+ }
76
+
77
+ // Step 2: Upload file directly to storage.
78
+ //
79
+ // Active Storage's Disk service issues an absolute URL against the Rails
80
+ // origin (e.g. http://localhost:3000/rails/active_storage/disk/...). In
81
+ // dev the SPA runs on a different port and that controller doesn't speak
82
+ // CORS, so the browser blocks the cross-origin PUT before any response
83
+ // ("Failed to fetch"). When the URL points at a `/rails/active_storage/`
84
+ // path we drop the host so the request goes through Vite's `/rails`
85
+ // proxy and stays same-origin. Production S3 URLs are untouched.
86
+ const uploadUrl = sameOriginIfRailsDisk(response.direct_upload.url)
87
+ const uploadResponse = await fetch(uploadUrl, {
88
+ method: 'PUT',
89
+ headers: response.direct_upload.headers,
90
+ body: file,
91
+ })
92
+
93
+ if (!uploadResponse.ok) {
94
+ const text = await uploadResponse.text().catch(() => '')
95
+ throw new Error(`Storage upload failed (${uploadResponse.status}): ${text}`)
96
+ }
97
+
98
+ return {
99
+ signedId: response.signed_id,
100
+ previewUrl: URL.createObjectURL(file),
101
+ }
102
+ },
103
+ })
104
+ }
@@ -0,0 +1,42 @@
1
+ import { useMemo } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+
4
+ /**
5
+ * Resolves a code to its display name via `Intl.DisplayNames`, localized to a
6
+ * given UI language. Returns `undefined` when the runtime lacks coverage so
7
+ * callers can apply their own fallback (the API name, the raw code, etc.).
8
+ *
9
+ * Standalone (non-hook) variant for module-level use outside React.
10
+ */
11
+ export function intlDisplayName(
12
+ type: Intl.DisplayNamesType,
13
+ locale: string,
14
+ code: string,
15
+ ): string | undefined {
16
+ try {
17
+ return new Intl.DisplayNames([locale, 'en'], { type }).of(code)
18
+ } catch {
19
+ return undefined
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Hook returning a memoized `code -> name` resolver for one `Intl.DisplayNames`
25
+ * type (`currency`, `language`, `region`, …), localized to the **admin UI
26
+ * language** (`i18n.language`) — NOT the store's content locale — so names
27
+ * match the rest of the dashboard chrome. Returns `undefined` on a miss; the
28
+ * caller supplies the fallback (e.g. `?? code`, `?? country.name`).
29
+ */
30
+ export function useDisplayName(type: Intl.DisplayNamesType) {
31
+ const { i18n: instance } = useTranslation()
32
+ const locale = instance.language
33
+ return useMemo(() => {
34
+ let formatter: Intl.DisplayNames | undefined
35
+ try {
36
+ formatter = new Intl.DisplayNames([locale, 'en'], { type })
37
+ } catch {
38
+ formatter = undefined
39
+ }
40
+ return (code: string) => formatter?.of(code)
41
+ }, [locale, type])
42
+ }