@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spree/dashboard-core",
3
3
  "license": "MIT",
4
- "version": "6.0.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.0 <7.0.0-0",
53
- "@spree/dashboard-ui": ">=6.0.0 <7.0.0-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.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/dashboard-ui": "^6.0.0",
85
- "@spree/admin-sdk": "^6.0.0"
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, Getting Started) skip the permission filter, so the
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
+ }