@spree/seller-dashboard 1.0.0-beta.3 → 1.0.0-beta.5

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@spree/seller-dashboard",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Spree marketplace seller panel — the seller-facing counterpart to the admin dashboard.",
@@ -45,13 +45,13 @@
45
45
  "@tanstack/router-plugin": "^1.167.35",
46
46
  "@tanstack/virtual-file-routes": "^1.162.0",
47
47
  "i18next": "^26.2.0",
48
- "lucide-react": "^0.545.0",
48
+ "lucide-react": "^1.47.0",
49
49
  "react-hook-form": "^7.65.0",
50
50
  "react-i18next": "^17.0.8",
51
51
  "zod": "^4.4.3",
52
- "@spree/dashboard-core": "1.0.0-beta.3",
53
- "@spree/seller-sdk": "1.0.0-beta.2",
54
- "@spree/dashboard-ui": "1.0.0-beta.3"
52
+ "@spree/dashboard-ui": "1.0.0-beta.5",
53
+ "@spree/seller-sdk": "1.0.0-beta.3",
54
+ "@spree/dashboard-core": "1.0.0-beta.5"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "react": "^19.0.0",
package/src/api-client.ts CHANGED
@@ -56,7 +56,7 @@ export function createSellerApiClient({
56
56
  // and 403 them against a seller they may hold no role on.
57
57
  clearTenant: forgetSeller,
58
58
  fetchPermissions: async () => {
59
- const response = await sellerClient().me()
59
+ const response = await sellerClient().me.get()
60
60
 
61
61
  // Both shapes, exactly as the operator's dashboard receives them. The
62
62
  // framework's `<Can>` reads CanCanCan rules; the key list is what the
@@ -64,6 +64,10 @@ export function createSellerApiClient({
64
64
  // did) left every `<Can>` on the panel answering false — silently.
65
65
  return { rules: response.permissions ?? [], keys: response.permission_keys ?? [] }
66
66
  },
67
+ // The account menu's language switcher and the profile dialog both write
68
+ // through this, so neither reaches for the Admin API from a panel holding
69
+ // only a seller credential.
70
+ updateAccount: (params) => sellerClient().me.update(params),
67
71
  // The shared address form reads countries through the registered client,
68
72
  // so it works in a panel that has no admin credential.
69
73
  listCountries: () => sellerClient().countries.list(),
@@ -0,0 +1,270 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import {
3
+ ImageUploadField,
4
+ i18n,
5
+ mapSpreeErrorsToForm,
6
+ switchLocale,
7
+ useAuth,
8
+ } from '@spree/dashboard-core'
9
+ import {
10
+ Button,
11
+ Dialog,
12
+ DialogBody,
13
+ DialogContent,
14
+ DialogDescription,
15
+ DialogFooter,
16
+ DialogHeader,
17
+ DialogTitle,
18
+ ErrorState,
19
+ Field,
20
+ FieldError,
21
+ FieldLabel,
22
+ Input,
23
+ Select,
24
+ SelectContent,
25
+ SelectItem,
26
+ SelectTrigger,
27
+ SelectValue,
28
+ Skeleton,
29
+ toastManager,
30
+ } from '@spree/dashboard-ui'
31
+ import type { MeResponse } from '@spree/seller-sdk'
32
+ import { useEffect, useMemo } from 'react'
33
+ import { Controller, useForm } from 'react-hook-form'
34
+ import { useTranslation } from 'react-i18next'
35
+ import { useAccount, useUpdateAccount } from '../hooks/use-account'
36
+ import { getAvailableUiLocales } from '../i18n'
37
+ import {
38
+ type AccountFormValues,
39
+ accountFormSchema,
40
+ accountToForm,
41
+ accountToParams,
42
+ } from '../schemas/account'
43
+
44
+ // The language the panel is currently displaying — persisted in localStorage
45
+ // and applied by i18next at boot. Used as the fallback when the account has no
46
+ // saved `selected_locale`, so the picker reflects what the person actually
47
+ // sees (and a save persists it) rather than starting empty and matching no
48
+ // option.
49
+ function currentUiLocale(): string {
50
+ const available = getAvailableUiLocales().map((locale) => locale.code)
51
+ const active = i18n.resolvedLanguage ?? i18n.language
52
+ return active && available.includes(active) ? active : 'en'
53
+ }
54
+
55
+ /**
56
+ * Edit-account dialog for the signed-in person — their name, photo and the
57
+ * panel's language. Opened from the sidebar's account menu; it has no page of
58
+ * its own.
59
+ *
60
+ * Not to be confused with the profile page, which is the seller business they
61
+ * act for. This is theirs, and follows them between sellers.
62
+ */
63
+ export function AccountDialog({
64
+ open,
65
+ onOpenChange,
66
+ }: {
67
+ open: boolean
68
+ onOpenChange: (open: boolean) => void
69
+ }) {
70
+ const { t } = useTranslation()
71
+ // Mounted on every page of the panel; don't fetch until it opens.
72
+ const { data: me, isLoading, error, refetch } = useAccount(open)
73
+
74
+ return (
75
+ <Dialog open={open} onOpenChange={onOpenChange}>
76
+ <DialogContent>
77
+ <DialogHeader>
78
+ <DialogTitle>{t('account.title')}</DialogTitle>
79
+ <DialogDescription>{t('account.subtitle')}</DialogDescription>
80
+ </DialogHeader>
81
+ {error ? (
82
+ <DialogBody>
83
+ <ErrorState
84
+ title={t('account.load_failed_title')}
85
+ description={error instanceof Error ? error.message : undefined}
86
+ onRetry={() => refetch()}
87
+ />
88
+ </DialogBody>
89
+ ) : isLoading || !me ? (
90
+ <DialogBody className="flex flex-col gap-4">
91
+ <Skeleton className="h-16 w-16 rounded-md" />
92
+ <Skeleton className="h-9 w-full" />
93
+ <Skeleton className="h-9 w-full" />
94
+ <Skeleton className="h-9 w-full" />
95
+ </DialogBody>
96
+ ) : (
97
+ // Mount the form only once the account has loaded, so `useForm`
98
+ // initializes with concrete string defaults — that keeps the inputs
99
+ // and the Select controlled from their first render.
100
+ <AccountForm me={me} onOpenChange={onOpenChange} />
101
+ )}
102
+ </DialogContent>
103
+ </Dialog>
104
+ )
105
+ }
106
+
107
+ function AccountForm({
108
+ me,
109
+ onOpenChange,
110
+ }: {
111
+ me: MeResponse
112
+ onOpenChange: (open: boolean) => void
113
+ }) {
114
+ const { t } = useTranslation()
115
+ const { updateUser } = useAuth()
116
+ const updateMutation = useUpdateAccount()
117
+
118
+ const form = useForm<AccountFormValues>({
119
+ resolver: zodResolver(accountFormSchema),
120
+ defaultValues: accountToForm(me, currentUiLocale()),
121
+ })
122
+
123
+ // Re-baseline from the refetched account after a save (to surface the
124
+ // newly-persisted photo as the server image), unless there are unsaved
125
+ // edits in flight.
126
+ useEffect(() => {
127
+ if (form.formState.isDirty) return
128
+ form.reset(accountToForm(me, currentUiLocale()))
129
+ }, [me, form])
130
+
131
+ // Release the picked photo's object URL when it is replaced or the dialog
132
+ // unmounts. `ImageUploadField` hands the blob URL to the form, so the form
133
+ // owns revoking it — otherwise a `reset` (the re-baseline above) or a close
134
+ // drops the reference without freeing the blob. Double revokes are harmless.
135
+ const avatarPreviewUrl = form.watch('avatar_preview_url')
136
+ useEffect(() => {
137
+ if (!avatarPreviewUrl) return
138
+ return () => URL.revokeObjectURL(avatarPreviewUrl)
139
+ }, [avatarPreviewUrl])
140
+
141
+ const onSubmit = async (values: AccountFormValues) => {
142
+ try {
143
+ const updated = await updateMutation.mutateAsync(accountToParams(values))
144
+ // Reflect the new name and photo in the auth context (the sidebar's
145
+ // account row) straight away, rather than waiting for a token refresh.
146
+ updateUser(updated.user)
147
+ toastManager.add({ type: 'success', title: t('account.saved') })
148
+ // Closing unmounts the form, so the avatar state needs no preserving
149
+ // across the save — reopening re-hydrates from the mutation response
150
+ // that `useUpdateAccount` writes into the cache.
151
+ form.reset({ ...values, avatar_signed_id: null })
152
+ onOpenChange(false)
153
+ // A changed language takes effect by reloading in it.
154
+ const code = values.selected_locale
155
+ if (code && code !== i18n.language) switchLocale(code)
156
+ } catch (err) {
157
+ // Anything that is not a field error has already been toasted by the
158
+ // mutation hook.
159
+ mapSpreeErrorsToForm(err, form.setError)
160
+ }
161
+ }
162
+
163
+ // The panel's own shipped locale bundles, not the backend — those are what
164
+ // it can actually display. Hidden when fewer than two are installed, since
165
+ // there is then nothing to choose.
166
+ const localeOptions = useMemo(
167
+ () => getAvailableUiLocales().map((locale) => ({ value: locale.code, label: locale.name })),
168
+ [],
169
+ )
170
+ const showLanguagePicker = localeOptions.length >= 2
171
+
172
+ const { errors, isDirty, isSubmitting } = form.formState
173
+
174
+ return (
175
+ <form onSubmit={form.handleSubmit(onSubmit)} className="contents">
176
+ <DialogBody className="flex flex-col gap-4">
177
+ {errors.root?.message && (
178
+ <p className="text-destructive text-sm" role="alert">
179
+ {errors.root.message}
180
+ </p>
181
+ )}
182
+ <ImageUploadField
183
+ square
184
+ serverUrl={me.user.avatar_url}
185
+ label={t('account.avatar')}
186
+ help={t('account.avatar_help')}
187
+ value={{
188
+ signedId: form.watch('avatar_signed_id'),
189
+ previewUrl: form.watch('avatar_preview_url'),
190
+ cleared: form.watch('avatar_cleared'),
191
+ }}
192
+ onChange={(next) => {
193
+ form.setValue('avatar_signed_id', next.signedId, { shouldDirty: true })
194
+ form.setValue('avatar_preview_url', next.previewUrl, { shouldDirty: true })
195
+ form.setValue('avatar_cleared', next.cleared, { shouldDirty: true })
196
+ }}
197
+ />
198
+ <Field>
199
+ <FieldLabel htmlFor="account-email">{t('account.email')}</FieldLabel>
200
+ {/* Email is identity-bound; PATCH /me does not accept it. */}
201
+ <Input id="account-email" type="email" value={me.user.email} disabled />
202
+ </Field>
203
+ <Field>
204
+ <FieldLabel htmlFor="account-first-name">{t('account.first_name')}</FieldLabel>
205
+ <Input
206
+ id="account-first-name"
207
+ aria-invalid={!!errors.first_name || undefined}
208
+ {...form.register('first_name')}
209
+ />
210
+ <FieldError errors={[errors.first_name]} />
211
+ </Field>
212
+ <Field>
213
+ <FieldLabel htmlFor="account-last-name">{t('account.last_name')}</FieldLabel>
214
+ <Input
215
+ id="account-last-name"
216
+ aria-invalid={!!errors.last_name || undefined}
217
+ {...form.register('last_name')}
218
+ />
219
+ <FieldError errors={[errors.last_name]} />
220
+ </Field>
221
+ {showLanguagePicker && (
222
+ <Controller
223
+ name="selected_locale"
224
+ control={form.control}
225
+ render={({ field, fieldState }) => (
226
+ <Field>
227
+ <FieldLabel htmlFor="account-language">{t('account.language')}</FieldLabel>
228
+ <Select
229
+ items={localeOptions as never}
230
+ value={field.value}
231
+ onValueChange={field.onChange}
232
+ >
233
+ <SelectTrigger
234
+ id="account-language"
235
+ aria-invalid={!!fieldState.error || undefined}
236
+ >
237
+ <SelectValue />
238
+ </SelectTrigger>
239
+ <SelectContent>
240
+ {localeOptions.map((option) => (
241
+ <SelectItem key={option.value} value={option.value}>
242
+ {option.label}
243
+ </SelectItem>
244
+ ))}
245
+ </SelectContent>
246
+ </Select>
247
+ <FieldError errors={[fieldState.error]} />
248
+ </Field>
249
+ )}
250
+ />
251
+ )}
252
+ </DialogBody>
253
+ <DialogFooter>
254
+ <Button
255
+ type="button"
256
+ variant="outline"
257
+ onClick={() => onOpenChange(false)}
258
+ disabled={isSubmitting}
259
+ >
260
+ {t('common.cancel')}
261
+ </Button>
262
+ {/* Gated on `isDirty`: without it a pristine Save sends a PATCH and
263
+ toasts success having changed nothing. */}
264
+ <Button type="submit" disabled={!isDirty || isSubmitting}>
265
+ {isSubmitting ? t('account.saving') : t('common.save')}
266
+ </Button>
267
+ </DialogFooter>
268
+ </form>
269
+ )
270
+ }
@@ -42,7 +42,7 @@ export function SellerSwitcher() {
42
42
  // loaded by the page behind this.
43
43
  const { data: me, isLoading } = useQuery({
44
44
  queryKey: ['seller', 'me'],
45
- queryFn: () => sellerClient().me(),
45
+ queryFn: () => sellerClient().me.get(),
46
46
  })
47
47
 
48
48
  const { data: profile } = useQuery({
@@ -0,0 +1,49 @@
1
+ import { useResourceMutation } from '@spree/dashboard-core'
2
+ import type { AccountUpdateParams, MeResponse } from '@spree/seller-sdk'
3
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
4
+ import { useTranslation } from 'react-i18next'
5
+ import { sellerClient } from '../api-client'
6
+
7
+ /**
8
+ * The signed-in person's own account.
9
+ *
10
+ * Keyed without the seller, unlike almost everything else in this panel: the
11
+ * account is the same record whichever seller they are acting for, so scoping
12
+ * it per seller would refetch it on every switch and cache the same answer
13
+ * several times over.
14
+ *
15
+ * @param enabled Defer the request until it is needed. The dialog is mounted
16
+ * on every page of the panel but only fetches once it opens.
17
+ */
18
+ export function useAccount(enabled = true) {
19
+ return useQuery({
20
+ queryKey: ACCOUNT_KEY,
21
+ queryFn: () => sellerClient().me.get(),
22
+ enabled,
23
+ })
24
+ }
25
+
26
+ export function useUpdateAccount() {
27
+ const { t } = useTranslation()
28
+ const queryClient = useQueryClient()
29
+
30
+ return useResourceMutation<MeResponse, Error, AccountUpdateParams>({
31
+ mutationFn: (params) => sellerClient().me.update(params),
32
+ // The person is also a row on the team page, so their own name and photo
33
+ // there go stale on a self-edit unless that list is refetched. The hook
34
+ // injects the seller at position 1, which is what the team page keys on.
35
+ invalidate: [['seller', 'team']],
36
+ successMessage: false, // the dialog toasts success itself
37
+ // Field-level 422s never toast — the dialog shows them inline — so this
38
+ // covers everything else: a refusal, a server error, a lost connection.
39
+ errorMessage: t('account.save_failed'),
40
+ // Write the response into the cache rather than leaning on invalidation
41
+ // alone: the query is disabled while the dialog is closed, and an
42
+ // invalidated query with no enabled observer goes stale without
43
+ // refetching — so reopening would render the pre-save account, flashing
44
+ // back a photo that was just removed.
45
+ onSuccess: (updated) => queryClient.setQueryData(ACCOUNT_KEY, updated),
46
+ })
47
+ }
48
+
49
+ const ACCOUNT_KEY = ['seller-account'] as const
package/src/i18n.ts CHANGED
@@ -10,7 +10,7 @@ import '@spree/dashboard-core/lib/i18n'
10
10
  // The instance comes from core too, not a bare `i18next` import: a bundled
11
11
  // build can resolve a second copy of the package, and `addResourceBundle` on
12
12
  // that one throws. Core re-exports the object it initialised for exactly this.
13
- import { i18n } from '@spree/dashboard-core'
13
+ import { i18n, localesFromBundlePaths, setUiLocales } from '@spree/dashboard-core'
14
14
  import en from './locales/en.json'
15
15
 
16
16
  /**
@@ -23,4 +23,31 @@ import en from './locales/en.json'
23
23
  */
24
24
  i18n.addResourceBundle('en', 'translation', en, true, true)
25
25
 
26
+ // Every other language, imported EAGERLY so the active one registers
27
+ // synchronously at module load — before any table or registry module
28
+ // evaluates its `i18n.t(...)` labels. Switching language reloads the page, so
29
+ // only the booted language's strings are ever resolved; eager keeps that
30
+ // resolution synchronous and flash-free.
31
+ const panelLocales = import.meta.glob<{ default: Record<string, unknown> }>(
32
+ ['./locales/*.json', '!./locales/en.json'],
33
+ { eager: true },
34
+ )
35
+
36
+ for (const [path, mod] of Object.entries(panelLocales)) {
37
+ const code = path.replace('./locales/', '').replace('.json', '')
38
+ i18n.addResourceBundle(code, 'translation', mod.default, true, true)
39
+ }
40
+
41
+ setUiLocales(getAvailableUiLocales().map(({ code }) => code))
42
+
43
+ /**
44
+ * The languages this panel can display, as `{ code, name }` pairs for the
45
+ * account menu and the account dialog. Derived from the locale bundles the
46
+ * panel ships — not from the API, which knows nothing about what the client
47
+ * can render.
48
+ */
49
+ export function getAvailableUiLocales(): Array<{ code: string; name: string }> {
50
+ return localesFromBundlePaths(Object.keys(panelLocales))
51
+ }
52
+
26
53
  export default i18n