@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,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
|
+
}
|