@spree/seller-dashboard 1.0.0-beta.2 → 1.0.0-beta.4

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.
@@ -37,10 +37,12 @@ export function OrderPage() {
37
37
  if (isError) return <RetryableError onRetry={() => refetch()} />
38
38
  if (!order) return <CenteredMessage>{t('orders.not_found')}</CenteredMessage>
39
39
 
40
- const cancelable = order.status !== 'canceled'
41
40
  // Nothing can come back from an order that never went out, so the post-sale
42
41
  // cards would only render empty with a disabled button.
43
42
  const placed = !!order.completed_at
43
+ // Only a placed order can be cancelled — offering it on a draft gives the
44
+ // seller an action the backend always refuses.
45
+ const cancelable = placed && order.status !== 'canceled'
44
46
 
45
47
  return (
46
48
  <>
@@ -15,7 +15,7 @@ export function SellerPicker() {
15
15
  const { t } = useTranslation()
16
16
  const { data, isLoading, error } = useQuery({
17
17
  queryKey: ['seller', 'me'],
18
- queryFn: () => sellerClient().me(),
18
+ queryFn: () => sellerClient().me.get(),
19
19
  })
20
20
 
21
21
  if (isLoading) return <CenteredMessage>{t('common.loading')}</CenteredMessage>
@@ -1,4 +1,4 @@
1
- import { Can, getInitials, PageHeader, Slot } from '@spree/dashboard-core'
1
+ import { Can, getInitials, PageHeader, Slot, usePermissions } from '@spree/dashboard-core'
2
2
  import {
3
3
  Avatar,
4
4
  AvatarFallback,
@@ -283,6 +283,7 @@ function InvitationRow({ invitation }: { invitation: Invitation }) {
283
283
  const queryClient = useQueryClient()
284
284
  const confirm = useConfirm()
285
285
  const { copy } = useCopyToClipboard()
286
+ const { permissions } = usePermissions()
286
287
 
287
288
  const invalidate = () =>
288
289
  queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'invitations'] })
@@ -313,20 +314,37 @@ function InvitationRow({ invitation }: { invitation: Invitation }) {
313
314
  }),
314
315
  })
315
316
 
317
+ // The link carries the invitation's token, so it is fetched on demand
318
+ // rather than shipped with the listing.
319
+ const acceptanceLink = useMutation({
320
+ mutationFn: (id: string) => sellerClient().invitations.acceptanceLink(id),
321
+ })
322
+
316
323
  async function handleCopyLink() {
317
- // Path-only when no panel origin is configured; resolve against wherever
318
- // this panel is actually mounted. `BASE_URL` matters: served at /sellers
319
- // (the single-node topology), origin alone would produce a link that
320
- // misses the mount and 404s.
321
- const url = invitation.acceptance_url.startsWith('/')
322
- ? new URL(
323
- `.${invitation.acceptance_url}`,
324
- `${window.location.origin}${import.meta.env.BASE_URL}`,
325
- ).toString()
326
- : invitation.acceptance_url
327
-
328
- await copy(url)
329
- toastManager.add({ type: 'success', title: t('team.messages.link_copied') })
324
+ try {
325
+ const { acceptance_url } = await acceptanceLink.mutateAsync(invitation.id)
326
+ // Path-only when no panel origin is configured; resolve against wherever
327
+ // this panel is actually mounted. `BASE_URL` matters: served at /sellers
328
+ // (the single-node topology), origin alone would produce a link that
329
+ // misses the mount and 404s.
330
+ const url = acceptance_url.startsWith('/')
331
+ ? new URL(
332
+ `.${acceptance_url}`,
333
+ `${window.location.origin}${import.meta.env.BASE_URL}`,
334
+ ).toString()
335
+ : acceptance_url
336
+
337
+ if (!(await copy(url))) {
338
+ toastManager.add({ type: 'error', title: t('common.error') })
339
+ return
340
+ }
341
+ toastManager.add({ type: 'success', title: t('team.messages.link_copied') })
342
+ } catch (err) {
343
+ toastManager.add({
344
+ type: 'error',
345
+ title: err instanceof Error ? err.message : t('common.error'),
346
+ })
347
+ }
330
348
  }
331
349
 
332
350
  async function handleRevoke() {
@@ -371,6 +389,8 @@ function InvitationRow({ invitation }: { invitation: Invitation }) {
371
389
  key: 'copy',
372
390
  label: t('team.actions.copy_link'),
373
391
  icon: <LinkIcon className="size-4" />,
392
+ visible: permissions.can('update', 'seller_profile'),
393
+ disabled: acceptanceLink.isPending,
374
394
  onSelect: handleCopyLink,
375
395
  },
376
396
  {
@@ -1,7 +1,12 @@
1
1
  import { AppShell, AppShellProvider, TenantProvider } from '@spree/dashboard-core'
2
2
  import { useParams, useRouterState } from '@tanstack/react-router'
3
- import type { ReactNode } from 'react'
3
+ import { type ReactNode, useState } from 'react'
4
+ import { AccountDialog } from './components/account-dialog'
4
5
  import { SellerSwitcher } from './components/seller-switcher'
6
+ import { getAvailableUiLocales } from './i18n'
7
+
8
+ // Derived once from the shipped locale bundles — stable for the panel's life.
9
+ const UI_LOCALES = getAvailableUiLocales()
5
10
 
6
11
  /**
7
12
  * The panel's frame: the nav rail, and the page as a sheet beside it.
@@ -20,6 +25,10 @@ export function PanelChrome({ children }: { children: ReactNode }) {
20
25
  const inSettings = useRouterState({
21
26
  select: (state) => state.location.pathname.startsWith(`/${sellerId}/settings`),
22
27
  })
28
+ // The account is edited in a dialog rather than on a page, so the frame owns
29
+ // its open state — the trigger sits in the sidebar's account menu, which is
30
+ // mounted here and stays put across route changes.
31
+ const [accountOpen, setAccountOpen] = useState(false)
23
32
 
24
33
  return (
25
34
  // Scopes every shared query key to this seller, the way `StoreProvider`
@@ -27,7 +36,14 @@ export function PanelChrome({ children }: { children: ReactNode }) {
27
36
  // survive a switch and be shown under the next seller.
28
37
  <TenantProvider id={sellerId}>
29
38
  <AppShellProvider>
30
- <AppShell tenantId={sellerId} sidebarHeader={<SellerSwitcher />} inSettings={inSettings}>
39
+ <AppShell
40
+ tenantId={sellerId}
41
+ sidebarHeader={<SellerSwitcher />}
42
+ inSettings={inSettings}
43
+ uiLocales={UI_LOCALES}
44
+ onEditProfile={() => setAccountOpen(true)}
45
+ >
46
+ <AccountDialog open={accountOpen} onOpenChange={setAccountOpen} />
31
47
  {children}
32
48
  </AppShell>
33
49
  </AppShellProvider>
@@ -24,7 +24,7 @@ function SellerEntry() {
24
24
 
25
25
  const { data, isLoading, error } = useQuery({
26
26
  queryKey: ['seller', 'me'],
27
- queryFn: () => sellerClient().me(),
27
+ queryFn: () => sellerClient().me.get(),
28
28
  })
29
29
 
30
30
  const sellers = data?.sellers ?? []
@@ -0,0 +1,49 @@
1
+ import { accountFormToParams, isUiLocale } from '@spree/dashboard-core'
2
+ import type { AccountUpdateParams, MeResponse } from '@spree/seller-sdk'
3
+ import { z } from 'zod/v4'
4
+
5
+ // The signed-in person's own account (PATCH /me) — not the seller business
6
+ // they act for, which is `pages/profile.tsx`. Every field is optional: the
7
+ // panel asks for a name but does not require one.
8
+ //
9
+ // The panel's languages are NOT listed here. They are derived at render time
10
+ // from the locale bundles the panel ships (see `getAvailableUiLocales`), which
11
+ // is what it can actually display — the API knows nothing about that.
12
+ //
13
+ // The avatar is a small state machine: untouched (omit on save), uploaded
14
+ // (send the signed id), or cleared (send null to purge). `avatar_signed_id`
15
+ // carries a freshly direct-uploaded blob, `avatar_preview_url` a transient
16
+ // object URL for the just-picked file, and `avatar_cleared` flags a removal of
17
+ // the persisted photo.
18
+ export const accountFormSchema = z.object({
19
+ first_name: z.string().nullable().optional(),
20
+ last_name: z.string().nullable().optional(),
21
+ selected_locale: z.string().nullable().optional(),
22
+ avatar_signed_id: z.string().nullable(),
23
+ avatar_preview_url: z.string().nullable(),
24
+ avatar_cleared: z.boolean(),
25
+ })
26
+
27
+ export type AccountFormValues = z.infer<typeof accountFormSchema>
28
+
29
+ /** Hydrate the form from the current account (the `/me` response). */
30
+ export function accountToForm(me: MeResponse, fallbackLocale: string): AccountFormValues {
31
+ return {
32
+ first_name: me.user.first_name ?? '',
33
+ last_name: me.user.last_name ?? '',
34
+ // A language saved from another panel may not be one this panel ships;
35
+ // preselecting it would show a raw code and save it straight back.
36
+ selected_locale:
37
+ me.user.selected_locale && isUiLocale(me.user.selected_locale)
38
+ ? me.user.selected_locale
39
+ : fallbackLocale,
40
+ avatar_signed_id: null,
41
+ avatar_preview_url: null,
42
+ avatar_cleared: false,
43
+ }
44
+ }
45
+
46
+ /** Map the form to the PATCH /me params (drops the frontend-only fields). */
47
+ export function accountToParams(values: AccountFormValues): AccountUpdateParams {
48
+ return accountFormToParams(values)
49
+ }