@spree/dashboard-core 6.0.0 → 6.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -6
- package/src/api-client.ts +4 -0
- package/src/components/app-shell.tsx +4 -0
- package/src/components/app-sidebar.tsx +7 -1
- package/src/components/invitation-acceptance.tsx +411 -0
- package/src/components/record-metadata.tsx +118 -0
- package/src/components/table-toolbar.tsx +384 -122
- package/src/hooks/use-global-search.ts +1 -1
- package/src/index.ts +2 -0
- package/src/lib/search-registry.ts +1 -1
- package/src/locales/ar.json +31 -1
- package/src/locales/de.json +31 -1
- package/src/locales/en.json +31 -1
- package/src/locales/es.json +31 -1
- package/src/locales/fr.json +31 -1
- package/src/locales/ja.json +2122 -0
- package/src/locales/pl.json +31 -1
- package/src/locales/vi.json +2122 -0
- package/src/locales/zh-CN.json +31 -1
- package/src/pages/package-types.tsx +2 -0
- package/src/pages/stock-locations.tsx +2 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spree/dashboard-core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "6.0.
|
|
4
|
+
"version": "6.0.2",
|
|
5
5
|
"description": "Spree Dashboard framework — registries (table, nav, slot, settings-nav), providers (auth, permission, store), admin SDK client, generic infra hooks, and the defineDashboardPlugin extension API.",
|
|
6
6
|
"author": "Vendo Connect Inc.",
|
|
7
7
|
"homepage": "https://spreecommerce.org",
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"LICENSE"
|
|
50
50
|
],
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@spree/admin-sdk": ">=6.0.
|
|
53
|
-
"@spree/dashboard-ui": ">=6.0.
|
|
52
|
+
"@spree/admin-sdk": ">=6.0.2 <7.0.0-0",
|
|
53
|
+
"@spree/dashboard-ui": ">=6.0.2 <7.0.0-0",
|
|
54
54
|
"@tanstack/react-query": "^5.100.9",
|
|
55
55
|
"@tanstack/react-router": "^1.169.2",
|
|
56
56
|
"i18next": "^26.2.0",
|
|
@@ -70,19 +70,24 @@
|
|
|
70
70
|
"date-fns-tz": "^3.2.0",
|
|
71
71
|
"lucide-react": "^1.60.0",
|
|
72
72
|
"zod": "^4.6.7",
|
|
73
|
-
"@spree/core": "^6.0.
|
|
73
|
+
"@spree/core": "^6.0.2"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
76
|
"@tanstack/react-query": "^5.104.5",
|
|
77
77
|
"@tanstack/react-router": "^1.170.45",
|
|
78
78
|
"@types/react": "^19.2.14",
|
|
79
79
|
"@types/react-dom": "^19.2.3",
|
|
80
|
+
"i18next": "^26.5.3",
|
|
81
|
+
"react": "^19.3.1",
|
|
82
|
+
"react-dom": "^19.3.1",
|
|
83
|
+
"react-hook-form": "^7.90.0",
|
|
84
|
+
"react-i18next": "^17.1.0",
|
|
80
85
|
"tsup": "^9.0.0",
|
|
81
86
|
"typescript": "~7.0.2",
|
|
82
87
|
"vite": "^8.3.4",
|
|
83
88
|
"vitest": "^5.0.5",
|
|
84
|
-
"@spree/
|
|
85
|
-
"@spree/
|
|
89
|
+
"@spree/admin-sdk": "^6.0.2",
|
|
90
|
+
"@spree/dashboard-ui": "^6.0.2"
|
|
86
91
|
},
|
|
87
92
|
"scripts": {
|
|
88
93
|
"lint": "biome check src",
|
package/src/api-client.ts
CHANGED
|
@@ -362,6 +362,8 @@ export interface PanelPackageType extends PanelNamedRecord {
|
|
|
362
362
|
* editable.
|
|
363
363
|
*/
|
|
364
364
|
editable?: boolean
|
|
365
|
+
/** Operator-only; the seller panel's serializer leaves it out. */
|
|
366
|
+
metadata?: Record<string, unknown> | null
|
|
365
367
|
/** Whose packaging this is; null is the marketplace's own. */
|
|
366
368
|
seller_id?: string | null
|
|
367
369
|
seller_name?: string | null
|
|
@@ -653,6 +655,8 @@ export interface PanelStockLocation {
|
|
|
653
655
|
pickup_stock_policy?: string
|
|
654
656
|
pickup_ready_in_minutes?: number | null
|
|
655
657
|
pickup_instructions?: string | null
|
|
658
|
+
/** Operator-only; the seller panel's serializer leaves it out. */
|
|
659
|
+
external_references?: Record<string, string>
|
|
656
660
|
}
|
|
657
661
|
|
|
658
662
|
/**
|
|
@@ -32,6 +32,7 @@ export function AppShell({
|
|
|
32
32
|
uiLocales,
|
|
33
33
|
onEditProfile,
|
|
34
34
|
notifications,
|
|
35
|
+
sidebarWidget,
|
|
35
36
|
children,
|
|
36
37
|
}: {
|
|
37
38
|
/** Prefix for the nav links. Defaults to the route's `storeId`. */
|
|
@@ -49,6 +50,8 @@ export function AppShell({
|
|
|
49
50
|
* notifications bell. The seller panel passes none.
|
|
50
51
|
*/
|
|
51
52
|
notifications?: ReactNode
|
|
53
|
+
/** A card pinned above the account menu, such as the store setup checklist. */
|
|
54
|
+
sidebarWidget?: ReactNode
|
|
52
55
|
children: ReactNode
|
|
53
56
|
}) {
|
|
54
57
|
// The settings area brings a full-width nav of its own, and two stacked
|
|
@@ -90,6 +93,7 @@ export function AppShell({
|
|
|
90
93
|
uiLocales={uiLocales}
|
|
91
94
|
onEditProfile={onEditProfile}
|
|
92
95
|
notifications={notifications}
|
|
96
|
+
widget={sidebarWidget}
|
|
93
97
|
/>
|
|
94
98
|
{/* The sheet is exactly as tall as the viewport and scrolls internally,
|
|
95
99
|
rather than growing with the page and letting the document scroll.
|
|
@@ -130,6 +130,7 @@ export function AppSidebar({
|
|
|
130
130
|
uiLocales,
|
|
131
131
|
onEditProfile,
|
|
132
132
|
notifications,
|
|
133
|
+
widget,
|
|
133
134
|
...props
|
|
134
135
|
}: ComponentProps<typeof Sidebar> & {
|
|
135
136
|
/**
|
|
@@ -145,6 +146,8 @@ export function AppSidebar({
|
|
|
145
146
|
onEditProfile?: () => void
|
|
146
147
|
/** Shown beside the account menu, such as a notifications bell. */
|
|
147
148
|
notifications?: ReactNode
|
|
149
|
+
/** A card pinned above the account menu. */
|
|
150
|
+
widget?: ReactNode
|
|
148
151
|
}) {
|
|
149
152
|
const { i18n } = useTranslation()
|
|
150
153
|
const { storeId } = useParams({ strict: false }) as { storeId?: string }
|
|
@@ -175,7 +178,7 @@ export function AppSidebar({
|
|
|
175
178
|
shell's shape steady and say the difference honestly.
|
|
176
179
|
|
|
177
180
|
Keyed on `isLoading` alone, not on an empty list: entries without a
|
|
178
|
-
`subject` (Home
|
|
181
|
+
`subject` (Home) skip the permission filter, so the
|
|
179
182
|
list is never actually empty and a length check would never fire. */}
|
|
180
183
|
{isLoading ? (
|
|
181
184
|
<NavSkeleton />
|
|
@@ -186,6 +189,8 @@ export function AppSidebar({
|
|
|
186
189
|
alone below a screen of empty space, so on a phone it follows
|
|
187
190
|
the list. */}
|
|
188
191
|
{isMobile && <NavBottom items={bottomItems} className="mb-2" />}
|
|
192
|
+
{/* The footer is hidden on a phone, so the widget follows the list. */}
|
|
193
|
+
{isMobile && widget && <div className="px-2 pb-2">{widget}</div>}
|
|
189
194
|
</>
|
|
190
195
|
)}
|
|
191
196
|
</SidebarContent>
|
|
@@ -193,6 +198,7 @@ export function AppSidebar({
|
|
|
193
198
|
list fades out above it rather than scrolling it away. */}
|
|
194
199
|
{!isLoading && !isMobile && <NavBottom items={bottomItems} className="py-2" />}
|
|
195
200
|
<SidebarFooter className="hidden md:flex">
|
|
201
|
+
{widget}
|
|
196
202
|
{/* In icon mode the rail is too narrow for a row, so the control
|
|
197
203
|
stacks above the avatar. */}
|
|
198
204
|
<div
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
|
+
import type { SpreeError } from '@spree/admin-sdk'
|
|
3
|
+
import { Button, Input, Label } from '@spree/dashboard-ui'
|
|
4
|
+
import { useQuery } from '@tanstack/react-query'
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { useForm } from 'react-hook-form'
|
|
7
|
+
import { useTranslation } from 'react-i18next'
|
|
8
|
+
import { z } from 'zod/v4'
|
|
9
|
+
import { useAuth } from '../hooks/use-auth'
|
|
10
|
+
import { mapSpreeErrorsToForm } from '../lib/form-errors'
|
|
11
|
+
import { i18n } from '../lib/i18n'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What the acceptance page needs to know about an invitation, as both the
|
|
15
|
+
* Admin and the Seller API lookups answer it. `target` is what the invitee
|
|
16
|
+
* joins: the store on the operator's dashboard, a seller on the seller panel.
|
|
17
|
+
*/
|
|
18
|
+
export interface InvitationSummary {
|
|
19
|
+
email: string
|
|
20
|
+
invitee_exists: boolean
|
|
21
|
+
inviter_email?: string | null
|
|
22
|
+
role_name?: string | null
|
|
23
|
+
target: { id: string; name: string }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface InvitationAcceptanceProps {
|
|
27
|
+
invitationId: string
|
|
28
|
+
/** From the emailed link's query string; the page refuses to go on without it. */
|
|
29
|
+
token?: string
|
|
30
|
+
lookup: (invitationId: string, token: string) => Promise<InvitationSummary>
|
|
31
|
+
/** Runs once the invitee's session is established, to take them into what they joined. */
|
|
32
|
+
onAccepted: (invitation: InvitationSummary) => void | Promise<void>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Lazy message factories: module-scope `i18n.t(...)` would lock the English
|
|
36
|
+
// string in at import time, breaking locale switches.
|
|
37
|
+
const passwordRequired = () => i18n.t('admin.validation.password_required')
|
|
38
|
+
const firstNameRequired = () => i18n.t('admin.validation.first_name_required')
|
|
39
|
+
const lastNameRequired = () => i18n.t('admin.validation.last_name_required')
|
|
40
|
+
const passwordsDontMatch = () => i18n.t('admin.validation.passwords_dont_match')
|
|
41
|
+
const passwordMinLength = () =>
|
|
42
|
+
i18n.t('admin.validation.min_length', { field: i18n.t('admin.fields.password.label'), count: 8 })
|
|
43
|
+
|
|
44
|
+
/** Existing invitee confirming with their current password. */
|
|
45
|
+
const signInSchema = z.object({
|
|
46
|
+
password: z.string().min(1, { error: passwordRequired }),
|
|
47
|
+
})
|
|
48
|
+
type SignInValues = z.infer<typeof signInSchema>
|
|
49
|
+
|
|
50
|
+
/** New invitee creating an account inline — mirrors the server's `AdminUser` validations. */
|
|
51
|
+
const signUpSchema = z
|
|
52
|
+
.object({
|
|
53
|
+
first_name: z.string().min(1, { error: firstNameRequired }),
|
|
54
|
+
last_name: z.string().min(1, { error: lastNameRequired }),
|
|
55
|
+
password: z.string().min(8, { error: passwordMinLength }),
|
|
56
|
+
password_confirmation: z.string(),
|
|
57
|
+
})
|
|
58
|
+
.refine((data) => data.password === data.password_confirmation, {
|
|
59
|
+
error: passwordsDontMatch,
|
|
60
|
+
path: ['password_confirmation'],
|
|
61
|
+
})
|
|
62
|
+
type SignUpValues = z.infer<typeof signUpSchema>
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The screens behind an invitation link, shared by both panels. The host
|
|
66
|
+
* wraps it in its own page layout and decides where an accepted invitee goes.
|
|
67
|
+
*
|
|
68
|
+
* Someone signed out sets a password (new account) or confirms theirs
|
|
69
|
+
* (existing account). Someone already signed in as the invitee accepts with
|
|
70
|
+
* one click; signed in as anyone else, they are asked to sign out first
|
|
71
|
+
* rather than having their session swapped silently.
|
|
72
|
+
*/
|
|
73
|
+
export function InvitationAcceptance({
|
|
74
|
+
invitationId,
|
|
75
|
+
token,
|
|
76
|
+
lookup,
|
|
77
|
+
onAccepted,
|
|
78
|
+
}: InvitationAcceptanceProps) {
|
|
79
|
+
const { t } = useTranslation()
|
|
80
|
+
const { isInitializing } = useAuth()
|
|
81
|
+
|
|
82
|
+
if (!token) {
|
|
83
|
+
return (
|
|
84
|
+
<ErrorCard
|
|
85
|
+
title={t('admin.invitation.missing_token_title')}
|
|
86
|
+
message={t('admin.invitation.missing_token_message')}
|
|
87
|
+
/>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Waits for the session check: it decides which form fits, and a failed one
|
|
92
|
+
// clears the query cache mid-lookup.
|
|
93
|
+
if (isInitializing) return <Loading />
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<InvitationLoader
|
|
97
|
+
invitationId={invitationId}
|
|
98
|
+
token={token}
|
|
99
|
+
lookup={lookup}
|
|
100
|
+
onAccepted={onAccepted}
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type LoadedProps = Required<InvitationAcceptanceProps>
|
|
106
|
+
|
|
107
|
+
function InvitationLoader({ invitationId, token, lookup, onAccepted }: LoadedProps) {
|
|
108
|
+
const { t } = useTranslation()
|
|
109
|
+
const query = useQuery({
|
|
110
|
+
queryKey: ['invitation-lookup', invitationId, token],
|
|
111
|
+
queryFn: () => lookup(invitationId, token),
|
|
112
|
+
retry: false,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
if (query.isPending) return <Loading />
|
|
116
|
+
|
|
117
|
+
if (query.isError) {
|
|
118
|
+
const err = query.error as SpreeError
|
|
119
|
+
return (
|
|
120
|
+
<ErrorCard
|
|
121
|
+
title={
|
|
122
|
+
err.status === 404
|
|
123
|
+
? t('admin.invitation.not_found_title')
|
|
124
|
+
: t('admin.invitation.generic_error_title')
|
|
125
|
+
}
|
|
126
|
+
message={
|
|
127
|
+
err.status === 404
|
|
128
|
+
? t('admin.invitation.not_found_message')
|
|
129
|
+
: err.message || t('admin.invitation.generic_error_message')
|
|
130
|
+
}
|
|
131
|
+
/>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<AcceptForm
|
|
137
|
+
invitationId={invitationId}
|
|
138
|
+
token={token}
|
|
139
|
+
invitation={query.data}
|
|
140
|
+
onAccepted={onAccepted}
|
|
141
|
+
/>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface FormProps {
|
|
146
|
+
invitationId: string
|
|
147
|
+
token: string
|
|
148
|
+
invitation: InvitationSummary
|
|
149
|
+
onAccepted: LoadedProps['onAccepted']
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function AcceptForm(props: FormProps) {
|
|
153
|
+
const { user } = useAuth()
|
|
154
|
+
const { invitation } = props
|
|
155
|
+
// Signing in through a form below sets `user` before the redirect lands;
|
|
156
|
+
// a page opened signed out keeps showing that form until then.
|
|
157
|
+
const [openedSignedOut] = useState(!user)
|
|
158
|
+
if (user && !openedSignedOut) {
|
|
159
|
+
return user.email.toLowerCase() === invitation.email.toLowerCase() ? (
|
|
160
|
+
<AcceptAsSignedIn {...props} />
|
|
161
|
+
) : (
|
|
162
|
+
<SignedInAsAnotherAccount invitation={invitation} />
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return invitation.invitee_exists ? <SignInForm {...props} /> : <SignUpForm {...props} />
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function SignInForm({ invitationId, token, invitation, onAccepted }: FormProps) {
|
|
170
|
+
const { t } = useTranslation()
|
|
171
|
+
const { acceptInvitation, isLoading } = useAuth()
|
|
172
|
+
|
|
173
|
+
const form = useForm<SignInValues>({
|
|
174
|
+
resolver: zodResolver(signInSchema),
|
|
175
|
+
defaultValues: { password: '' },
|
|
176
|
+
})
|
|
177
|
+
const { errors } = form.formState
|
|
178
|
+
|
|
179
|
+
const onSubmit = async (data: SignInValues) => {
|
|
180
|
+
try {
|
|
181
|
+
await acceptInvitation(invitationId, token, { password: data.password })
|
|
182
|
+
} catch (err) {
|
|
183
|
+
const e = err as SpreeError
|
|
184
|
+
if (e?.status === 401) {
|
|
185
|
+
form.setError('root', { message: t('admin.validation.invalid_password') })
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
if (!mapSpreeErrorsToForm(err, form.setError)) {
|
|
189
|
+
form.setError('root', { message: e?.message || t('admin.invitation.could_not_accept') })
|
|
190
|
+
}
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
await onAccepted(invitation)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<>
|
|
198
|
+
<InvitationIntro invitation={invitation} action={t('admin.invitation.sign_in_to_accept')} />
|
|
199
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6">
|
|
200
|
+
{errors.root && <p className="text-sm text-destructive">{errors.root.message}</p>}
|
|
201
|
+
<div className="grid gap-2">
|
|
202
|
+
<Label htmlFor="invitee-email">{t('admin.fields.email.label')}</Label>
|
|
203
|
+
<Input id="invitee-email" value={invitation.email} disabled />
|
|
204
|
+
</div>
|
|
205
|
+
<div className="grid gap-2">
|
|
206
|
+
<Label htmlFor="password">{t('admin.fields.password.label')}</Label>
|
|
207
|
+
<Input
|
|
208
|
+
id="password"
|
|
209
|
+
type="password"
|
|
210
|
+
autoFocus
|
|
211
|
+
aria-invalid={!!errors.password || undefined}
|
|
212
|
+
{...form.register('password')}
|
|
213
|
+
/>
|
|
214
|
+
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
|
|
215
|
+
</div>
|
|
216
|
+
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
217
|
+
{isLoading ? t('admin.invitation.accepting') : t('admin.invitation.sign_in_and_accept')}
|
|
218
|
+
</Button>
|
|
219
|
+
</form>
|
|
220
|
+
</>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function SignUpForm({ invitationId, token, invitation, onAccepted }: FormProps) {
|
|
225
|
+
const { t } = useTranslation()
|
|
226
|
+
const { acceptInvitation, isLoading } = useAuth()
|
|
227
|
+
|
|
228
|
+
const form = useForm<SignUpValues>({
|
|
229
|
+
resolver: zodResolver(signUpSchema),
|
|
230
|
+
defaultValues: { first_name: '', last_name: '', password: '', password_confirmation: '' },
|
|
231
|
+
})
|
|
232
|
+
const { errors } = form.formState
|
|
233
|
+
|
|
234
|
+
const onSubmit = async (data: SignUpValues) => {
|
|
235
|
+
try {
|
|
236
|
+
await acceptInvitation(invitationId, token, data)
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (!mapSpreeErrorsToForm(err, form.setError)) {
|
|
239
|
+
const e = err as SpreeError
|
|
240
|
+
form.setError('root', { message: e?.message || t('admin.invitation.could_not_accept') })
|
|
241
|
+
}
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
await onAccepted(invitation)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<>
|
|
249
|
+
<InvitationIntro invitation={invitation} action={t('admin.invitation.create_to_accept')} />
|
|
250
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6">
|
|
251
|
+
{errors.root && <p className="text-sm text-destructive">{errors.root.message}</p>}
|
|
252
|
+
<div className="grid gap-2">
|
|
253
|
+
<Label htmlFor="invitee-email">{t('admin.fields.email.label')}</Label>
|
|
254
|
+
<Input id="invitee-email" value={invitation.email} disabled />
|
|
255
|
+
</div>
|
|
256
|
+
<div className="grid grid-cols-2 gap-3">
|
|
257
|
+
<div className="grid gap-2">
|
|
258
|
+
<Label htmlFor="first_name">{t('admin.fields.first_name.label')}</Label>
|
|
259
|
+
<Input
|
|
260
|
+
id="first_name"
|
|
261
|
+
autoFocus
|
|
262
|
+
aria-invalid={!!errors.first_name || undefined}
|
|
263
|
+
{...form.register('first_name')}
|
|
264
|
+
/>
|
|
265
|
+
{errors.first_name && (
|
|
266
|
+
<p className="text-sm text-destructive">{errors.first_name.message}</p>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
<div className="grid gap-2">
|
|
270
|
+
<Label htmlFor="last_name">{t('admin.fields.last_name.label')}</Label>
|
|
271
|
+
<Input
|
|
272
|
+
id="last_name"
|
|
273
|
+
aria-invalid={!!errors.last_name || undefined}
|
|
274
|
+
{...form.register('last_name')}
|
|
275
|
+
/>
|
|
276
|
+
{errors.last_name && (
|
|
277
|
+
<p className="text-sm text-destructive">{errors.last_name.message}</p>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
<div className="grid gap-2">
|
|
282
|
+
<Label htmlFor="password">{t('admin.fields.password.label')}</Label>
|
|
283
|
+
<Input
|
|
284
|
+
id="password"
|
|
285
|
+
type="password"
|
|
286
|
+
aria-invalid={!!errors.password || undefined}
|
|
287
|
+
{...form.register('password')}
|
|
288
|
+
/>
|
|
289
|
+
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
|
|
290
|
+
</div>
|
|
291
|
+
<div className="grid gap-2">
|
|
292
|
+
<Label htmlFor="password_confirmation">
|
|
293
|
+
{t('admin.invitation.password_confirmation')}
|
|
294
|
+
</Label>
|
|
295
|
+
<Input
|
|
296
|
+
id="password_confirmation"
|
|
297
|
+
type="password"
|
|
298
|
+
aria-invalid={!!errors.password_confirmation || undefined}
|
|
299
|
+
{...form.register('password_confirmation')}
|
|
300
|
+
/>
|
|
301
|
+
{errors.password_confirmation && (
|
|
302
|
+
<p className="text-sm text-destructive">{errors.password_confirmation.message}</p>
|
|
303
|
+
)}
|
|
304
|
+
</div>
|
|
305
|
+
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
306
|
+
{isLoading ? t('admin.invitation.accepting') : t('admin.invitation.create_and_accept')}
|
|
307
|
+
</Button>
|
|
308
|
+
</form>
|
|
309
|
+
</>
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function AcceptAsSignedIn({ invitationId, token, invitation, onAccepted }: FormProps) {
|
|
314
|
+
const { t } = useTranslation()
|
|
315
|
+
const { user, acceptInvitation, logout, isLoading } = useAuth()
|
|
316
|
+
const [error, setError] = useState<string | null>(null)
|
|
317
|
+
|
|
318
|
+
const onAccept = async () => {
|
|
319
|
+
setError(null)
|
|
320
|
+
try {
|
|
321
|
+
await acceptInvitation(invitationId, token, {})
|
|
322
|
+
} catch (err) {
|
|
323
|
+
const e = err as SpreeError
|
|
324
|
+
// The session lapsed (a laptop that slept, say): signing out brings up
|
|
325
|
+
// the password form instead of an error with no way forward.
|
|
326
|
+
if (e?.status === 401) return logout()
|
|
327
|
+
setError(e?.message || t('admin.invitation.could_not_accept'))
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
await onAccepted(invitation)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
<>
|
|
335
|
+
<InvitationIntro
|
|
336
|
+
invitation={invitation}
|
|
337
|
+
action={t('admin.invitation.signed_in_as_invitee', { email: user?.email })}
|
|
338
|
+
/>
|
|
339
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
340
|
+
<Button className="w-full" disabled={isLoading} onClick={onAccept}>
|
|
341
|
+
{isLoading ? t('admin.invitation.accepting') : t('admin.invitation.accept')}
|
|
342
|
+
</Button>
|
|
343
|
+
</>
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function SignedInAsAnotherAccount({ invitation }: { invitation: InvitationSummary }) {
|
|
348
|
+
const { t } = useTranslation()
|
|
349
|
+
const { user, logout } = useAuth()
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<>
|
|
353
|
+
<div className="flex flex-col gap-2">
|
|
354
|
+
<h1 className="text-2xl font-bold">
|
|
355
|
+
{t('admin.invitation.join_store', { store: invitation.target.name })}
|
|
356
|
+
</h1>
|
|
357
|
+
<p className="text-sm text-muted-foreground">
|
|
358
|
+
{t('admin.invitation.signed_in_as_another_account', {
|
|
359
|
+
email: user?.email,
|
|
360
|
+
invitee: invitation.email,
|
|
361
|
+
})}
|
|
362
|
+
</p>
|
|
363
|
+
</div>
|
|
364
|
+
<Button variant="outline" className="w-full" onClick={() => logout()}>
|
|
365
|
+
{t('admin.auth.logout')}
|
|
366
|
+
</Button>
|
|
367
|
+
</>
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** "Join <target>" plus one sentence: who invited, as what, and what to do next. */
|
|
372
|
+
function InvitationIntro({
|
|
373
|
+
invitation,
|
|
374
|
+
action,
|
|
375
|
+
}: {
|
|
376
|
+
invitation: InvitationSummary
|
|
377
|
+
action: string
|
|
378
|
+
}) {
|
|
379
|
+
const { t } = useTranslation()
|
|
380
|
+
const invitedPart = invitation.inviter_email
|
|
381
|
+
? t('admin.invitation.invited_by', { email: invitation.inviter_email })
|
|
382
|
+
: t('admin.invitation.invited_generic')
|
|
383
|
+
const rolePart = invitation.role_name
|
|
384
|
+
? t('admin.invitation.invited_as_role', { role: invitation.role_name })
|
|
385
|
+
: ''
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
<div className="flex flex-col gap-2">
|
|
389
|
+
<h1 className="text-2xl font-bold">
|
|
390
|
+
{t('admin.invitation.join_store', { store: invitation.target.name })}
|
|
391
|
+
</h1>
|
|
392
|
+
<p className="text-sm text-muted-foreground">{`${invitedPart}${rolePart}${action}`}</p>
|
|
393
|
+
</div>
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function Loading() {
|
|
398
|
+
const { t } = useTranslation()
|
|
399
|
+
return (
|
|
400
|
+
<div className="py-12 text-center text-muted-foreground">{t('admin.invitation.loading')}</div>
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function ErrorCard({ title, message }: { title: string; message: string }) {
|
|
405
|
+
return (
|
|
406
|
+
<div className="flex flex-col gap-2">
|
|
407
|
+
<h1 className="text-2xl font-bold">{title}</h1>
|
|
408
|
+
<p className="text-sm text-muted-foreground">{message}</p>
|
|
409
|
+
</div>
|
|
410
|
+
)
|
|
411
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DropdownMenuItem,
|
|
3
|
+
hasExternalReferences,
|
|
4
|
+
hasMetadata,
|
|
5
|
+
MetadataCard,
|
|
6
|
+
MetadataSheet,
|
|
7
|
+
} from '@spree/dashboard-ui'
|
|
8
|
+
import { BracesIcon } from '@spree/dashboard-ui/icons'
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
import { useTranslation } from 'react-i18next'
|
|
11
|
+
|
|
12
|
+
type Metadata = Record<string, unknown> | null | undefined
|
|
13
|
+
type ExternalReferences = Record<string, string> | null | undefined
|
|
14
|
+
|
|
15
|
+
interface RecordIntegrationData {
|
|
16
|
+
metadata?: Metadata
|
|
17
|
+
/** The record's `external_references` map, on records that carry one. */
|
|
18
|
+
externalReferences?: ExternalReferences
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function useMetadataCopy() {
|
|
22
|
+
const { t } = useTranslation()
|
|
23
|
+
return {
|
|
24
|
+
title: t('admin.components.metadata_card.title'),
|
|
25
|
+
metadataTitle: t('admin.components.metadata_card.title'),
|
|
26
|
+
externalReferencesTitle: t('admin.components.metadata_card.external_references'),
|
|
27
|
+
copyLabel: (system: string) => t('admin.components.metadata_card.copy_reference', { system }),
|
|
28
|
+
emptyTitle: t('admin.components.metadata_card.empty_title'),
|
|
29
|
+
emptyDescription: t('admin.components.metadata_card.empty_description'),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hasIntegrationData({ metadata, externalReferences }: RecordIntegrationData) {
|
|
34
|
+
return hasMetadata(metadata) || hasExternalReferences(externalReferences)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Metadata card for the main column of a record's detail page. */
|
|
38
|
+
export function RecordMetadataCard({ metadata, externalReferences }: RecordIntegrationData) {
|
|
39
|
+
return (
|
|
40
|
+
<MetadataCard
|
|
41
|
+
metadata={metadata}
|
|
42
|
+
externalReferences={externalReferences}
|
|
43
|
+
{...useMetadataCopy()}
|
|
44
|
+
/>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Metadata of a row inside another page; pair with {@link ViewMetadataMenuItem}. */
|
|
49
|
+
function RecordMetadataSheet({
|
|
50
|
+
metadata,
|
|
51
|
+
externalReferences,
|
|
52
|
+
open,
|
|
53
|
+
onOpenChange,
|
|
54
|
+
description,
|
|
55
|
+
}: RecordIntegrationData & {
|
|
56
|
+
open: boolean
|
|
57
|
+
onOpenChange: (open: boolean) => void
|
|
58
|
+
/** Names the record, e.g. "Payment P123". */
|
|
59
|
+
description?: string
|
|
60
|
+
}) {
|
|
61
|
+
return (
|
|
62
|
+
<MetadataSheet
|
|
63
|
+
open={open}
|
|
64
|
+
onOpenChange={onOpenChange}
|
|
65
|
+
metadata={metadata}
|
|
66
|
+
externalReferences={externalReferences}
|
|
67
|
+
description={description}
|
|
68
|
+
{...useMetadataCopy()}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One metadata sheet for a list of rows: `show` it from a row's menu and render
|
|
75
|
+
* `sheet` once beside the list. The record stays set while the sheet animates
|
|
76
|
+
* closed, so it never flashes the empty state on the way out.
|
|
77
|
+
*/
|
|
78
|
+
export function useMetadataSheet() {
|
|
79
|
+
const [viewing, setViewing] = useState<RecordIntegrationData & { description?: string }>()
|
|
80
|
+
const [open, setOpen] = useState(false)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
show(metadata: Metadata, description?: string, externalReferences?: ExternalReferences) {
|
|
84
|
+
setViewing({ metadata, description, externalReferences })
|
|
85
|
+
setOpen(true)
|
|
86
|
+
},
|
|
87
|
+
sheet: (
|
|
88
|
+
<RecordMetadataSheet
|
|
89
|
+
metadata={viewing?.metadata}
|
|
90
|
+
externalReferences={viewing?.externalReferences}
|
|
91
|
+
description={viewing?.description}
|
|
92
|
+
open={open}
|
|
93
|
+
onOpenChange={setOpen}
|
|
94
|
+
/>
|
|
95
|
+
),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Renders nothing when the record has no metadata, so a menu needs no check of its own. */
|
|
100
|
+
export function ViewMetadataMenuItem({
|
|
101
|
+
onClick,
|
|
102
|
+
...data
|
|
103
|
+
}: RecordIntegrationData & { onClick: () => void }) {
|
|
104
|
+
const { t } = useTranslation()
|
|
105
|
+
if (!hasIntegrationData(data)) return null
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<DropdownMenuItem onClick={onClick}>
|
|
109
|
+
<BracesIcon className="size-4" />
|
|
110
|
+
{t('admin.components.metadata_card.view')}
|
|
111
|
+
</DropdownMenuItem>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Metadata at the bottom of a record's edit sheet; renders nothing when there is none. */
|
|
116
|
+
export function RecordMetadataSection(data: RecordIntegrationData) {
|
|
117
|
+
return hasIntegrationData(data) ? <RecordMetadataCard {...data} /> : null
|
|
118
|
+
}
|