@spree/dashboard-core 0.13.1 → 1.0.0-beta.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/README.md +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +52 -27
- package/src/components/import-wizard-dialog.tsx +710 -0
- package/src/components/locale-select.tsx +70 -57
- package/src/components/media-picker-sheet.tsx +307 -0
- package/src/components/mobile-top-bar.tsx +75 -0
- package/src/components/nav-main.tsx +197 -27
- package/src/components/page-header.tsx +203 -61
- package/src/components/page-tabs.tsx +4 -1
- package/src/components/preferences-form.tsx +98 -4
- package/src/components/resource-combobox.tsx +52 -4
- package/src/components/resource-picker-sheet.tsx +209 -73
- package/src/components/resource-table.tsx +383 -127
- package/src/components/route-announcer.tsx +64 -0
- package/src/components/section-heading.tsx +95 -0
- package/src/components/settings-sidebar.tsx +290 -60
- package/src/components/sheet-top-bar.tsx +44 -0
- package/src/components/sidebar-search.tsx +83 -0
- package/src/components/sidebar-user.tsx +213 -0
- package/src/components/skip-link.tsx +36 -0
- package/src/components/store-date-picker.tsx +6 -2
- package/src/components/store-switcher.tsx +19 -14
- package/src/components/table-toolbar.tsx +1357 -440
- package/src/components/tag-combobox.tsx +12 -4
- package/src/components/top-bar-breadcrumbs.tsx +235 -0
- package/src/components/wizard-close-guard.test.ts +54 -0
- package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
- package/src/hooks/use-command-palette.tsx +13 -0
- package/src/hooks/use-countries.test.ts +56 -0
- package/src/hooks/use-countries.ts +51 -5
- package/src/hooks/use-direct-upload.ts +16 -3
- package/src/hooks/use-export.ts +51 -16
- package/src/hooks/use-import.ts +241 -16
- package/src/hooks/use-package-types.ts +132 -0
- package/src/hooks/use-resource-mutation.ts +74 -13
- package/src/hooks/use-stock-locations.ts +131 -0
- package/src/hooks/use-switch-admin-locale.ts +2 -2
- package/src/hooks/use-visual-viewport-offset.ts +61 -0
- package/src/index.ts +45 -1
- package/src/lib/cancel-order-form.ts +27 -0
- package/src/lib/create-action-registry.ts +278 -0
- package/src/lib/date-presets.test.ts +86 -0
- package/src/lib/date-presets.ts +98 -0
- package/src/lib/delivery-form.ts +15 -0
- package/src/lib/docs.ts +32 -0
- package/src/lib/download.ts +13 -1
- package/src/lib/filters-to-ransack.test.ts +113 -0
- package/src/lib/filters-to-ransack.ts +22 -1
- package/src/lib/form-errors.ts +122 -10
- package/src/lib/form-mappers.ts +76 -0
- package/src/lib/fulfillment-items.test.ts +213 -0
- package/src/lib/fulfillment-items.ts +171 -0
- package/src/lib/i18n.ts +19 -11
- package/src/lib/import-types.ts +25 -0
- package/src/lib/label-upload-form.ts +22 -0
- package/src/lib/nav-registry.ts +71 -3
- package/src/lib/permissions.ts +46 -1
- package/src/lib/progress.ts +18 -0
- package/src/lib/query-keys.ts +13 -13
- package/src/lib/sample-csv.ts +9 -6
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/table-registry.ts +50 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -517
- package/src/locales/de.json +1884 -517
- package/src/locales/en.json +1886 -519
- package/src/locales/fr.json +1884 -517
- package/src/locales/pl.json +1913 -517
- package/src/locales/zh-CN.json +1883 -517
- package/src/pages/package-types.tsx +484 -0
- package/src/pages/settings-index.tsx +112 -0
- package/src/pages/stock-locations.tsx +674 -0
- package/src/plugin.ts +29 -0
- package/src/products/add-video-dialog.tsx +100 -0
- package/src/products/bulk-variants-dialog.tsx +301 -0
- package/src/products/carton-size.test.ts +51 -0
- package/src/products/carton-size.ts +20 -0
- package/src/products/currency-parts.ts +21 -0
- package/src/products/inventory-section.tsx +259 -0
- package/src/products/media-edit-sheet.tsx +336 -0
- package/src/products/normalize-customs.test.ts +39 -0
- package/src/products/normalize-customs.ts +27 -0
- package/src/products/normalize-money.test.ts +58 -0
- package/src/products/normalize-money.ts +73 -0
- package/src/products/normalize-quantity.test.ts +32 -0
- package/src/products/normalize-quantity.ts +31 -0
- package/src/products/product-bulk-price-editor.tsx +165 -0
- package/src/products/product-form-cards.tsx +1167 -0
- package/src/products/product-form-mapping.ts +212 -0
- package/src/products/product-schema.ts +318 -0
- package/src/products/product-types.ts +130 -0
- package/src/products/use-product-form-data.ts +273 -0
- package/src/products/variant-edit-sheet.tsx +725 -0
- package/src/products/variants-matrix.test.ts +154 -0
- package/src/products/variants-matrix.ts +244 -0
- package/src/products/variants-options-builder.tsx +571 -0
- package/src/products/variants-section.tsx +535 -0
- package/src/products/video-url.test.ts +71 -0
- package/src/products/video-url.ts +83 -0
- package/src/providers/auth-provider.tsx +70 -12
- package/src/providers/permission-provider.tsx +68 -28
- package/src/providers/sticky-header-provider.tsx +134 -0
- package/src/providers/store-provider.tsx +36 -3
- package/src/providers/tenant-provider.tsx +33 -0
- package/src/schemas/package-type.ts +95 -0
- package/src/schemas/stock-location.ts +126 -0
- package/src/tables/package-types.tsx +74 -0
- package/src/tables/stock-locations.tsx +124 -0
- package/src/vite/index.ts +40 -7
- package/src/components/top-bar.tsx +0 -209
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Field,
|
|
5
|
+
FieldDescription,
|
|
6
|
+
FieldError,
|
|
7
|
+
FieldGroup,
|
|
8
|
+
FieldLabel,
|
|
9
|
+
Input,
|
|
10
|
+
RowActions,
|
|
11
|
+
Select,
|
|
12
|
+
SelectContent,
|
|
13
|
+
SelectItem,
|
|
14
|
+
SelectTrigger,
|
|
15
|
+
SelectValue,
|
|
16
|
+
Sheet,
|
|
17
|
+
SheetContent,
|
|
18
|
+
SheetDescription,
|
|
19
|
+
SheetFooter,
|
|
20
|
+
SheetHeader,
|
|
21
|
+
SheetTitle,
|
|
22
|
+
Switch,
|
|
23
|
+
useConfirm,
|
|
24
|
+
useRowClickBridge,
|
|
25
|
+
} from '@spree/dashboard-ui'
|
|
26
|
+
import { PlusIcon } from '@spree/dashboard-ui/icons'
|
|
27
|
+
import { useNavigate } from '@tanstack/react-router'
|
|
28
|
+
import { useEffect } from 'react'
|
|
29
|
+
import { Controller, type UseFormReturn, useForm } from 'react-hook-form'
|
|
30
|
+
import { useTranslation } from 'react-i18next'
|
|
31
|
+
import { z } from 'zod/v4'
|
|
32
|
+
import type { PanelPackageType } from '../api-client'
|
|
33
|
+
import { Can } from '../components/can'
|
|
34
|
+
import { ResourceTable, resourceSearchSchema } from '../components/resource-table'
|
|
35
|
+
import {
|
|
36
|
+
canDeletePackageTypes,
|
|
37
|
+
canWritePackageTypes,
|
|
38
|
+
listPackageTypes,
|
|
39
|
+
useCreatePackageType,
|
|
40
|
+
useDeletePackageType,
|
|
41
|
+
usePackageType,
|
|
42
|
+
useUpdatePackageType,
|
|
43
|
+
} from '../hooks/use-package-types'
|
|
44
|
+
import { mapSpreeErrorsToForm } from '../lib/form-errors'
|
|
45
|
+
import { Subject } from '../lib/permissions'
|
|
46
|
+
import { usePermissions } from '../providers/permission-provider'
|
|
47
|
+
import {
|
|
48
|
+
PACKAGE_DIMENSION_UNITS,
|
|
49
|
+
PACKAGE_TYPE_DEFAULTS,
|
|
50
|
+
PACKAGE_TYPE_KINDS,
|
|
51
|
+
PACKAGE_WEIGHT_UNITS,
|
|
52
|
+
type PackageTypeFormValues,
|
|
53
|
+
packageTypeFormSchema,
|
|
54
|
+
packageTypeFormValuesToParams,
|
|
55
|
+
packageTypeToFormValues,
|
|
56
|
+
} from '../schemas/package-type'
|
|
57
|
+
import '../tables/package-types'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Adds `?edit=<id>` and `?new=1` on top of the standard table search schema,
|
|
61
|
+
* so the create and edit sheets can be deep-linked.
|
|
62
|
+
*
|
|
63
|
+
* Exported because each app owns its own route file — the route path differs
|
|
64
|
+
* per panel (`/$storeId/settings/...` against `/$sellerId/settings/...`) and
|
|
65
|
+
* TanStack's file routes are generated per app — while everything the page
|
|
66
|
+
* *does* lives here.
|
|
67
|
+
*/
|
|
68
|
+
export const packageTypesSearchSchema = resourceSearchSchema.extend({
|
|
69
|
+
edit: z.string().optional(),
|
|
70
|
+
new: z.coerce.boolean().optional(),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
export type PackageTypesSearch = z.infer<typeof packageTypesSearchSchema>
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Manage the packaging this panel can see: the boxes parcels ship in, the
|
|
77
|
+
* cartons products are packed into, the pallets a wholesale order leaves on.
|
|
78
|
+
*
|
|
79
|
+
* Shared by the operator's dashboard and the marketplace seller panel. The
|
|
80
|
+
* scoping is server-side — the operator's API answers with every row in the
|
|
81
|
+
* store and a seller's with their own plus the marketplace's shared
|
|
82
|
+
* vocabulary — so this page renders whatever it is given
|
|
83
|
+
* (docs/plans/6.0-seller-package-types.md).
|
|
84
|
+
*
|
|
85
|
+
* One row can be listed but not written: a seller sees the marketplace's
|
|
86
|
+
* packaging so they know what they may pack into, and it reports
|
|
87
|
+
* `editable: false`. The operator's serializer carries no such field because
|
|
88
|
+
* every row there is theirs, so an absent flag reads as editable.
|
|
89
|
+
*/
|
|
90
|
+
function noop() {}
|
|
91
|
+
|
|
92
|
+
export function PackageTypesPage({ search }: { search: PackageTypesSearch }) {
|
|
93
|
+
const { t } = useTranslation()
|
|
94
|
+
const navigate = useNavigate()
|
|
95
|
+
const confirm = useConfirm()
|
|
96
|
+
const deleteMutation = useDeletePackageType()
|
|
97
|
+
const { permissions } = usePermissions()
|
|
98
|
+
|
|
99
|
+
// A panel may register `packageTypes.list` alone for the variant editor's
|
|
100
|
+
// carton picker. Reading it here keeps this page honest on such a panel:
|
|
101
|
+
// no add button, no row actions, and no sheet whose save could not work.
|
|
102
|
+
const writable = canWritePackageTypes()
|
|
103
|
+
|
|
104
|
+
// Neither sheet opens on a read-only panel. No mutual exclusion needed:
|
|
105
|
+
// openCreate strips `edit` and openEdit strips `new`, so a link can only
|
|
106
|
+
// carry one.
|
|
107
|
+
const isCreating = writable && !!search.new
|
|
108
|
+
const editId = writable ? search.edit : undefined
|
|
109
|
+
|
|
110
|
+
function closeSheet() {
|
|
111
|
+
navigate({
|
|
112
|
+
search: (prev: Record<string, unknown>) => {
|
|
113
|
+
const { edit: _e, new: _n, ...rest } = prev
|
|
114
|
+
return rest as never
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function openCreate() {
|
|
120
|
+
navigate({
|
|
121
|
+
search: (prev: Record<string, unknown>) => {
|
|
122
|
+
const { edit: _e, ...rest } = prev
|
|
123
|
+
return { ...rest, new: true } as never
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function openEdit(id: string) {
|
|
129
|
+
navigate({
|
|
130
|
+
search: (prev: Record<string, unknown>) => {
|
|
131
|
+
const { new: _n, ...rest } = prev
|
|
132
|
+
return { ...rest, edit: id } as never
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Guarded on `writable` as well: on a read-only panel no sheet can open, so
|
|
138
|
+
// a click would only push an `edit` param nothing consumes or clears.
|
|
139
|
+
useRowClickBridge('data-package-type-id', writable ? openEdit : noop)
|
|
140
|
+
|
|
141
|
+
async function handleDelete(packageType: PanelPackageType) {
|
|
142
|
+
const ok = await confirm({
|
|
143
|
+
title: t('admin.package_types.delete_confirm.title'),
|
|
144
|
+
message: t('admin.package_types.delete_confirm.message', { name: packageType.name }),
|
|
145
|
+
variant: 'destructive',
|
|
146
|
+
confirmLabel: t('admin.actions.delete'),
|
|
147
|
+
})
|
|
148
|
+
if (!ok) return
|
|
149
|
+
await deleteMutation.mutateAsync(packageType.id).catch(() => undefined)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<>
|
|
154
|
+
<ResourceTable<PanelPackageType>
|
|
155
|
+
tableKey="package-types"
|
|
156
|
+
queryKey="package-types"
|
|
157
|
+
queryFn={listPackageTypes}
|
|
158
|
+
searchParams={search}
|
|
159
|
+
rowActions={(packageType) => {
|
|
160
|
+
// A marketplace row in a seller's list is read-only, so it gets no
|
|
161
|
+
// actions at all rather than actions that 404 on click. Same for
|
|
162
|
+
// every row when this panel's client registered reads only.
|
|
163
|
+
if (packageType.editable === false || !writable) return null
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<RowActions
|
|
167
|
+
actions={[
|
|
168
|
+
{
|
|
169
|
+
key: 'edit',
|
|
170
|
+
visible: permissions.can('update', Subject.PackageType),
|
|
171
|
+
onSelect: () => openEdit(packageType.id),
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
key: 'delete',
|
|
175
|
+
destructive: true,
|
|
176
|
+
visible:
|
|
177
|
+
canDeletePackageTypes() && permissions.can('destroy', Subject.PackageType),
|
|
178
|
+
disabled: deleteMutation.isPending,
|
|
179
|
+
onSelect: () => handleDelete(packageType),
|
|
180
|
+
},
|
|
181
|
+
]}
|
|
182
|
+
/>
|
|
183
|
+
)
|
|
184
|
+
}}
|
|
185
|
+
actions={
|
|
186
|
+
writable ? (
|
|
187
|
+
<Can I="create" a={Subject.PackageType}>
|
|
188
|
+
<Button size="sm" className="h-[2.125rem]" onClick={openCreate}>
|
|
189
|
+
<PlusIcon className="size-4" />
|
|
190
|
+
{t('admin.package_types.add_cta')}
|
|
191
|
+
</Button>
|
|
192
|
+
</Can>
|
|
193
|
+
) : null
|
|
194
|
+
}
|
|
195
|
+
/>
|
|
196
|
+
|
|
197
|
+
{isCreating && <CreatePackageTypeSheet open onOpenChange={(o) => !o && closeSheet()} />}
|
|
198
|
+
{editId && <EditPackageTypeSheet id={editId} open onOpenChange={(o) => !o && closeSheet()} />}
|
|
199
|
+
</>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function CreatePackageTypeSheet({
|
|
204
|
+
open,
|
|
205
|
+
onOpenChange,
|
|
206
|
+
}: {
|
|
207
|
+
open: boolean
|
|
208
|
+
onOpenChange: (open: boolean) => void
|
|
209
|
+
}) {
|
|
210
|
+
const { t } = useTranslation()
|
|
211
|
+
const createMutation = useCreatePackageType()
|
|
212
|
+
const form = useForm<PackageTypeFormValues>({
|
|
213
|
+
resolver: zodResolver(packageTypeFormSchema),
|
|
214
|
+
defaultValues: PACKAGE_TYPE_DEFAULTS,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
async function onSubmit(values: PackageTypeFormValues) {
|
|
218
|
+
try {
|
|
219
|
+
await createMutation.mutateAsync(packageTypeFormValuesToParams(values))
|
|
220
|
+
form.reset(PACKAGE_TYPE_DEFAULTS)
|
|
221
|
+
onOpenChange(false)
|
|
222
|
+
} catch (err) {
|
|
223
|
+
if (!mapSpreeErrorsToForm(err, form.setError)) throw err
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<Sheet
|
|
229
|
+
open={open}
|
|
230
|
+
onOpenChange={(next) => {
|
|
231
|
+
if (!next) form.reset(PACKAGE_TYPE_DEFAULTS)
|
|
232
|
+
onOpenChange(next)
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
<SheetContent>
|
|
236
|
+
<SheetHeader>
|
|
237
|
+
<SheetTitle>{t('admin.pages.settings.package_types.add_sheet_title')}</SheetTitle>
|
|
238
|
+
<SheetDescription>{t('admin.package_types.create_description')}</SheetDescription>
|
|
239
|
+
</SheetHeader>
|
|
240
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col">
|
|
241
|
+
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
|
|
242
|
+
<PackageTypeFormFields form={form} />
|
|
243
|
+
</div>
|
|
244
|
+
<SheetFooter>
|
|
245
|
+
<Button type="submit" disabled={createMutation.isPending}>
|
|
246
|
+
{t('admin.actions.save')}
|
|
247
|
+
</Button>
|
|
248
|
+
</SheetFooter>
|
|
249
|
+
</form>
|
|
250
|
+
</SheetContent>
|
|
251
|
+
</Sheet>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function EditPackageTypeSheet({
|
|
256
|
+
id,
|
|
257
|
+
open,
|
|
258
|
+
onOpenChange,
|
|
259
|
+
}: {
|
|
260
|
+
id: string
|
|
261
|
+
open: boolean
|
|
262
|
+
onOpenChange: (open: boolean) => void
|
|
263
|
+
}) {
|
|
264
|
+
const { t } = useTranslation()
|
|
265
|
+
const { data: packageType } = usePackageType(id)
|
|
266
|
+
const updateMutation = useUpdatePackageType(id)
|
|
267
|
+
// A pasted link can name a row this panel may read but not write — the
|
|
268
|
+
// marketplace's packaging in a seller's panel. Closing rather than
|
|
269
|
+
// rendering a form whose save could only 404.
|
|
270
|
+
const readOnly = packageType?.editable === false
|
|
271
|
+
const form = useForm<PackageTypeFormValues>({
|
|
272
|
+
resolver: zodResolver(packageTypeFormSchema),
|
|
273
|
+
defaultValues: PACKAGE_TYPE_DEFAULTS,
|
|
274
|
+
values: packageType ? packageTypeToFormValues(packageType) : undefined,
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
async function onSubmit(values: PackageTypeFormValues) {
|
|
278
|
+
try {
|
|
279
|
+
await updateMutation.mutateAsync(packageTypeFormValuesToParams(values))
|
|
280
|
+
onOpenChange(false)
|
|
281
|
+
} catch (err) {
|
|
282
|
+
if (!mapSpreeErrorsToForm(err, form.setError)) throw err
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
if (readOnly) onOpenChange(false)
|
|
288
|
+
}, [readOnly, onOpenChange])
|
|
289
|
+
|
|
290
|
+
if (readOnly) return null
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
294
|
+
<SheetContent>
|
|
295
|
+
<SheetHeader>
|
|
296
|
+
<SheetTitle>{packageType?.name ?? t('admin.package_types.edit_sheet_title')}</SheetTitle>
|
|
297
|
+
<SheetDescription>{t('admin.package_types.edit_description')}</SheetDescription>
|
|
298
|
+
</SheetHeader>
|
|
299
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col">
|
|
300
|
+
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
|
|
301
|
+
<PackageTypeFormFields form={form} />
|
|
302
|
+
</div>
|
|
303
|
+
<SheetFooter>
|
|
304
|
+
<Button type="submit" disabled={updateMutation.isPending}>
|
|
305
|
+
{t('admin.actions.save')}
|
|
306
|
+
</Button>
|
|
307
|
+
</SheetFooter>
|
|
308
|
+
</form>
|
|
309
|
+
</SheetContent>
|
|
310
|
+
</Sheet>
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function PackageTypeFormFields({ form }: { form: UseFormReturn<PackageTypeFormValues> }) {
|
|
315
|
+
const { t } = useTranslation()
|
|
316
|
+
const errors = form.formState.errors
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<FieldGroup>
|
|
320
|
+
<Field>
|
|
321
|
+
<FieldLabel htmlFor="package-type-name">
|
|
322
|
+
{t('admin.fields.package_type.name.label')}
|
|
323
|
+
</FieldLabel>
|
|
324
|
+
<Input id="package-type-name" {...form.register('name')} aria-invalid={!!errors.name} />
|
|
325
|
+
<FieldError errors={[errors.name]} />
|
|
326
|
+
</Field>
|
|
327
|
+
|
|
328
|
+
<Field>
|
|
329
|
+
<FieldLabel htmlFor="package-type-kind">
|
|
330
|
+
{t('admin.fields.package_type.kind.label')}
|
|
331
|
+
</FieldLabel>
|
|
332
|
+
<Controller
|
|
333
|
+
control={form.control}
|
|
334
|
+
name="kind"
|
|
335
|
+
render={({ field }) => {
|
|
336
|
+
const options = PACKAGE_TYPE_KINDS.map((kind) => ({
|
|
337
|
+
value: kind,
|
|
338
|
+
label: t(`admin.package_types.kinds.${kind}`),
|
|
339
|
+
}))
|
|
340
|
+
return (
|
|
341
|
+
<Select items={options} value={field.value} onValueChange={field.onChange}>
|
|
342
|
+
<SelectTrigger id="package-type-kind">
|
|
343
|
+
<SelectValue />
|
|
344
|
+
</SelectTrigger>
|
|
345
|
+
<SelectContent>
|
|
346
|
+
{options.map((option) => (
|
|
347
|
+
<SelectItem key={option.value} value={option.value}>
|
|
348
|
+
{option.label}
|
|
349
|
+
</SelectItem>
|
|
350
|
+
))}
|
|
351
|
+
</SelectContent>
|
|
352
|
+
</Select>
|
|
353
|
+
)
|
|
354
|
+
}}
|
|
355
|
+
/>
|
|
356
|
+
<FieldDescription>{t('admin.fields.package_type.kind.help')}</FieldDescription>
|
|
357
|
+
</Field>
|
|
358
|
+
|
|
359
|
+
<div className="grid grid-cols-[1fr_1fr_1fr_110px] gap-3">
|
|
360
|
+
{(['length', 'width', 'height'] as const).map((side) => (
|
|
361
|
+
<Field key={side}>
|
|
362
|
+
<FieldLabel htmlFor={`package-type-${side}`}>
|
|
363
|
+
{t(`admin.fields.package_type.${side}.label`)}
|
|
364
|
+
</FieldLabel>
|
|
365
|
+
<Input
|
|
366
|
+
id={`package-type-${side}`}
|
|
367
|
+
type="number"
|
|
368
|
+
min="0"
|
|
369
|
+
step="0.01"
|
|
370
|
+
aria-invalid={!!errors[side]}
|
|
371
|
+
{...form.register(side)}
|
|
372
|
+
/>
|
|
373
|
+
<FieldError errors={[errors[side]]} />
|
|
374
|
+
</Field>
|
|
375
|
+
))}
|
|
376
|
+
<UnitField
|
|
377
|
+
form={form}
|
|
378
|
+
name="dimensions_unit"
|
|
379
|
+
units={PACKAGE_DIMENSION_UNITS}
|
|
380
|
+
label={t('admin.fields.package_type.dimensions_unit.label')}
|
|
381
|
+
/>
|
|
382
|
+
</div>
|
|
383
|
+
<FieldDescription>{t('admin.fields.package_type.dimensions.help')}</FieldDescription>
|
|
384
|
+
|
|
385
|
+
<div className="grid grid-cols-[1fr_1fr_110px] gap-3">
|
|
386
|
+
<Field>
|
|
387
|
+
<FieldLabel htmlFor="package-type-weight">
|
|
388
|
+
{t('admin.fields.package_type.weight.label')}
|
|
389
|
+
</FieldLabel>
|
|
390
|
+
<Input
|
|
391
|
+
id="package-type-weight"
|
|
392
|
+
type="number"
|
|
393
|
+
min="0"
|
|
394
|
+
step="0.01"
|
|
395
|
+
aria-invalid={!!errors.weight}
|
|
396
|
+
{...form.register('weight')}
|
|
397
|
+
/>
|
|
398
|
+
<FieldError errors={[errors.weight]} />
|
|
399
|
+
<FieldDescription>{t('admin.fields.package_type.weight.help')}</FieldDescription>
|
|
400
|
+
</Field>
|
|
401
|
+
<Field>
|
|
402
|
+
<FieldLabel htmlFor="package-type-max-weight">
|
|
403
|
+
{t('admin.fields.package_type.max_weight.label')}
|
|
404
|
+
</FieldLabel>
|
|
405
|
+
<Input
|
|
406
|
+
id="package-type-max-weight"
|
|
407
|
+
type="number"
|
|
408
|
+
min="0"
|
|
409
|
+
step="0.01"
|
|
410
|
+
aria-invalid={!!errors.max_weight}
|
|
411
|
+
{...form.register('max_weight')}
|
|
412
|
+
/>
|
|
413
|
+
<FieldError errors={[errors.max_weight]} />
|
|
414
|
+
<FieldDescription>{t('admin.fields.package_type.max_weight.help')}</FieldDescription>
|
|
415
|
+
</Field>
|
|
416
|
+
<UnitField
|
|
417
|
+
form={form}
|
|
418
|
+
name="weight_unit"
|
|
419
|
+
units={PACKAGE_WEIGHT_UNITS}
|
|
420
|
+
label={t('admin.fields.package_type.weight_unit.label')}
|
|
421
|
+
/>
|
|
422
|
+
</div>
|
|
423
|
+
|
|
424
|
+
<Field>
|
|
425
|
+
<div className="flex items-start justify-between gap-4">
|
|
426
|
+
<FieldLabel htmlFor="package-type-default" className="cursor-pointer">
|
|
427
|
+
{t('admin.fields.package_type.default.label')}
|
|
428
|
+
</FieldLabel>
|
|
429
|
+
<Controller
|
|
430
|
+
control={form.control}
|
|
431
|
+
name="default"
|
|
432
|
+
render={({ field }) => (
|
|
433
|
+
<Switch
|
|
434
|
+
id="package-type-default"
|
|
435
|
+
checked={!!field.value}
|
|
436
|
+
onCheckedChange={field.onChange}
|
|
437
|
+
/>
|
|
438
|
+
)}
|
|
439
|
+
/>
|
|
440
|
+
</div>
|
|
441
|
+
<FieldDescription>{t('admin.fields.package_type.default.help')}</FieldDescription>
|
|
442
|
+
</Field>
|
|
443
|
+
</FieldGroup>
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function UnitField({
|
|
448
|
+
form,
|
|
449
|
+
name,
|
|
450
|
+
units,
|
|
451
|
+
label,
|
|
452
|
+
}: {
|
|
453
|
+
form: UseFormReturn<PackageTypeFormValues>
|
|
454
|
+
name: 'dimensions_unit' | 'weight_unit'
|
|
455
|
+
units: readonly string[]
|
|
456
|
+
label: string
|
|
457
|
+
}) {
|
|
458
|
+
return (
|
|
459
|
+
<Field>
|
|
460
|
+
<FieldLabel htmlFor={`package-type-${name}`}>{label}</FieldLabel>
|
|
461
|
+
<Controller
|
|
462
|
+
control={form.control}
|
|
463
|
+
name={name}
|
|
464
|
+
render={({ field }) => {
|
|
465
|
+
const options = units.map((unit) => ({ value: unit, label: unit }))
|
|
466
|
+
return (
|
|
467
|
+
<Select items={options} value={field.value ?? ''} onValueChange={field.onChange}>
|
|
468
|
+
<SelectTrigger id={`package-type-${name}`}>
|
|
469
|
+
<SelectValue />
|
|
470
|
+
</SelectTrigger>
|
|
471
|
+
<SelectContent>
|
|
472
|
+
{options.map((option) => (
|
|
473
|
+
<SelectItem key={option.value} value={option.value}>
|
|
474
|
+
{option.label}
|
|
475
|
+
</SelectItem>
|
|
476
|
+
))}
|
|
477
|
+
</SelectContent>
|
|
478
|
+
</Select>
|
|
479
|
+
)
|
|
480
|
+
}}
|
|
481
|
+
/>
|
|
482
|
+
</Field>
|
|
483
|
+
)
|
|
484
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Card, SearchInput } from '@spree/dashboard-ui'
|
|
2
|
+
import { PackageIcon } from '@spree/dashboard-ui/icons'
|
|
3
|
+
import { Link } from '@tanstack/react-router'
|
|
4
|
+
import { useMemo, useState } from 'react'
|
|
5
|
+
import { useTranslation } from 'react-i18next'
|
|
6
|
+
import { PageHeader } from '../components/page-header'
|
|
7
|
+
import { resolveNavDescription, resolveNavLabel } from '../lib/nav-registry'
|
|
8
|
+
import {
|
|
9
|
+
filterSettingsByPermissions,
|
|
10
|
+
filterSettingsByQuery,
|
|
11
|
+
type SettingsNavEntry,
|
|
12
|
+
useSettingsNav,
|
|
13
|
+
} from '../lib/settings-nav-registry'
|
|
14
|
+
import { usePermissions } from '../providers/permission-provider'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Settings landing page — a card grid of every settings area the current
|
|
18
|
+
* member can reach, grouped the same way the sidebar groups them.
|
|
19
|
+
*
|
|
20
|
+
* Shared by the operator dashboard and the marketplace seller panel. Each
|
|
21
|
+
* host passes its tenant id so the cards link under that panel's prefix
|
|
22
|
+
* (`/$storeId/settings/…` or `/$sellerId/settings/…`).
|
|
23
|
+
*
|
|
24
|
+
* It doubles as the settings navigation below `lg`, where `SettingsSidebar` is
|
|
25
|
+
* hidden: on a narrow viewport this grid is the only way into the area, so it
|
|
26
|
+
* must never be replaced by a redirect to a specific page.
|
|
27
|
+
*/
|
|
28
|
+
export function SettingsIndexPage({ tenantId }: { tenantId: string }) {
|
|
29
|
+
const { t } = useTranslation()
|
|
30
|
+
const snapshot = useSettingsNav()
|
|
31
|
+
const { permissions } = usePermissions()
|
|
32
|
+
const [query, setQuery] = useState('')
|
|
33
|
+
|
|
34
|
+
// Permission filtering depends only on the snapshot, so it survives typing.
|
|
35
|
+
const allowed = useMemo(
|
|
36
|
+
() => filterSettingsByPermissions(snapshot, permissions),
|
|
37
|
+
[snapshot, permissions],
|
|
38
|
+
)
|
|
39
|
+
const groups = useMemo(() => filterSettingsByQuery(allowed, query, t).groups, [allowed, query, t])
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="flex flex-col gap-6">
|
|
43
|
+
<PageHeader
|
|
44
|
+
title={t('admin.settings_page.title')}
|
|
45
|
+
subtitle={t('admin.settings_page.description')}
|
|
46
|
+
/>
|
|
47
|
+
|
|
48
|
+
{/* Hidden from `lg`, where the settings sidebar is on screen listing the
|
|
49
|
+
same entries and carrying the same search. Below that the sidebar is
|
|
50
|
+
gone, so this is the only way to filter — and the only navigation. */}
|
|
51
|
+
<div className="lg:hidden">
|
|
52
|
+
<SearchInput
|
|
53
|
+
value={query}
|
|
54
|
+
onValueChange={setQuery}
|
|
55
|
+
placeholder={t('admin.settings_page.search_placeholder')}
|
|
56
|
+
aria-label={t('admin.settings_page.search_placeholder')}
|
|
57
|
+
clearLabel={t('admin.common.clear')}
|
|
58
|
+
className="h-11 max-w-md"
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{groups.length === 0 ? (
|
|
63
|
+
<p className="text-sm text-muted-foreground">
|
|
64
|
+
{t('admin.settings_page.no_results', { query: query.trim() })}
|
|
65
|
+
</p>
|
|
66
|
+
) : (
|
|
67
|
+
groups.map(({ group, entries }) => (
|
|
68
|
+
<section key={group.key} className="flex flex-col gap-3">
|
|
69
|
+
<h2 className="text-sm font-medium text-muted-foreground">
|
|
70
|
+
{resolveNavLabel(group, t)}
|
|
71
|
+
</h2>
|
|
72
|
+
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
73
|
+
{entries.map((entry) => (
|
|
74
|
+
<SettingsCard key={entry.key} entry={entry} tenantId={tenantId} />
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
</section>
|
|
78
|
+
))
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function SettingsCard({ entry, tenantId }: { entry: SettingsNavEntry; tenantId: string }) {
|
|
85
|
+
const { t } = useTranslation()
|
|
86
|
+
const Icon = entry.icon ?? PackageIcon
|
|
87
|
+
const label = resolveNavLabel(entry, t)
|
|
88
|
+
const description = resolveNavDescription(entry, t)
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<Card className="p-0 transition-colors hover:bg-accent/80 hover:border-border">
|
|
92
|
+
{/* The whole card is the target — a small title-only link would fail the
|
|
93
|
+
44px minimum on touch and leaves most of the card dead to a click. */}
|
|
94
|
+
{/* Paths come from a runtime registry plugins extend, so they can't be
|
|
95
|
+
checked against the generated route tree. */}
|
|
96
|
+
<Link
|
|
97
|
+
to={`/${tenantId}/settings${entry.path}` as never}
|
|
98
|
+
// The whole card is the target, so without a replacement for the
|
|
99
|
+
// suppressed outline a keyboard user tabbing the grid cannot tell which
|
|
100
|
+
// card they are on. A solid ring rather than the border pair buttons
|
|
101
|
+
// use: this link draws no border of its own to recolour.
|
|
102
|
+
className="flex h-full items-start gap-3 rounded-xl p-4 outline-none focus-visible:shadow-[0_0_0_2px_var(--ring)]"
|
|
103
|
+
>
|
|
104
|
+
<Icon className="mt-0.5 size-6 shrink-0 text-muted-foreground opacity-80 hover:opacity-100" />
|
|
105
|
+
<span className="flex min-w-0 flex-col gap-1">
|
|
106
|
+
<span className="text-sm font-medium leading-none">{label}</span>
|
|
107
|
+
{description && <span className="text-sm text-muted-foreground">{description}</span>}
|
|
108
|
+
</span>
|
|
109
|
+
</Link>
|
|
110
|
+
</Card>
|
|
111
|
+
)
|
|
112
|
+
}
|