@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,571 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Badge,
|
|
3
|
+
Button,
|
|
4
|
+
Field,
|
|
5
|
+
FieldLabel,
|
|
6
|
+
Input,
|
|
7
|
+
Select,
|
|
8
|
+
SelectContent,
|
|
9
|
+
SelectItem,
|
|
10
|
+
SelectTrigger,
|
|
11
|
+
SelectValue,
|
|
12
|
+
toastManager,
|
|
13
|
+
} from '@spree/dashboard-ui'
|
|
14
|
+
import { CheckIcon, PencilIcon, PlusIcon, XIcon } from '@spree/dashboard-ui/icons'
|
|
15
|
+
import { useMemo, useState } from 'react'
|
|
16
|
+
import { useTranslation } from 'react-i18next'
|
|
17
|
+
import { getApiClient, type PanelOptionType as OptionType } from '../api-client'
|
|
18
|
+
import {
|
|
19
|
+
useCreateOptionType,
|
|
20
|
+
useFormOptionTypes as useOptionTypes,
|
|
21
|
+
useUpdateOptionType,
|
|
22
|
+
} from './use-product-form-data'
|
|
23
|
+
import { autoExpandIndex, type SelectedOptionType } from './variants-matrix'
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
selected: SelectedOptionType[]
|
|
27
|
+
onChange: (next: SelectedOptionType[]) => void
|
|
28
|
+
/**
|
|
29
|
+
* Option types resolved by id for the product's type. Merged over the paged
|
|
30
|
+
* registry so a type referencing one past the first page still renders its
|
|
31
|
+
* row and its values picker.
|
|
32
|
+
*/
|
|
33
|
+
extraOptionTypes?: OptionType[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The options builder lets the merchant choose option types + values that
|
|
37
|
+
// drive the variants matrix. It DOES NOT mutate the product itself — it
|
|
38
|
+
// only updates the local `selected` state. The matrix component owns the
|
|
39
|
+
// reconcile step that turns this state into RHF variant rows.
|
|
40
|
+
export function VariantsOptionsBuilder({ selected, onChange, extraOptionTypes }: Props) {
|
|
41
|
+
const { t } = useTranslation()
|
|
42
|
+
const { data: optionTypesData } = useOptionTypes()
|
|
43
|
+
const allOptionTypes = useMemo(() => {
|
|
44
|
+
const paged = optionTypesData?.data ?? []
|
|
45
|
+
if (!extraOptionTypes?.length) return paged
|
|
46
|
+
|
|
47
|
+
const byId = new Map(paged.map((optionType) => [optionType.id, optionType]))
|
|
48
|
+
extraOptionTypes.forEach((optionType) => {
|
|
49
|
+
if (!byId.has(optionType.id)) byId.set(optionType.id, optionType)
|
|
50
|
+
})
|
|
51
|
+
return [...byId.values()]
|
|
52
|
+
}, [optionTypesData, extraOptionTypes])
|
|
53
|
+
|
|
54
|
+
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
|
55
|
+
const [isAdding, setIsAdding] = useState(false)
|
|
56
|
+
|
|
57
|
+
// A row with no values yet is one the merchant still has to act on — the
|
|
58
|
+
// product type seeds exactly that. Open the first one so the values picker
|
|
59
|
+
// is in front of them instead of hidden behind a click. Only while nothing
|
|
60
|
+
// else is being edited, so it never steals focus mid-interaction.
|
|
61
|
+
//
|
|
62
|
+
// It is an offer they can decline: without tracking the dismissal, cancelling
|
|
63
|
+
// reopens the row on the next render (it is still empty, so it qualifies
|
|
64
|
+
// again) and its remove action stays unreachable.
|
|
65
|
+
// A set, not one id: a type seeding two option types produces two empty rows,
|
|
66
|
+
// and declining the second must not re-open the first.
|
|
67
|
+
const [dismissedAutoExpand, setDismissedAutoExpand] = useState<ReadonlySet<string>>(new Set())
|
|
68
|
+
const offeredIndex = autoExpandIndex(selected, dismissedAutoExpand)
|
|
69
|
+
const expandedIndex = editingIndex ?? (isAdding || offeredIndex < 0 ? null : offeredIndex)
|
|
70
|
+
|
|
71
|
+
const selectedIds = new Set(selected.map((s) => s.id))
|
|
72
|
+
const availableTypes = allOptionTypes.filter((ot) => !selectedIds.has(ot.id))
|
|
73
|
+
|
|
74
|
+
const removeAt = (index: number) => {
|
|
75
|
+
// Drop the dismissal with the row, so re-adding the same option type gets
|
|
76
|
+
// a fresh offer rather than inheriting a stale one.
|
|
77
|
+
const removed = selected[index]
|
|
78
|
+
if (removed) {
|
|
79
|
+
setDismissedAutoExpand((prev) => {
|
|
80
|
+
if (!prev.has(removed.id)) return prev
|
|
81
|
+
|
|
82
|
+
const next = new Set(prev)
|
|
83
|
+
next.delete(removed.id)
|
|
84
|
+
return next
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
onChange(selected.filter((_, i) => i !== index))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const upsertAt = (index: number | null, next: SelectedOptionType) => {
|
|
91
|
+
if (index == null) {
|
|
92
|
+
onChange([...selected, { ...next, position: selected.length }])
|
|
93
|
+
} else {
|
|
94
|
+
onChange(selected.map((s, i) => (i === index ? next : s)))
|
|
95
|
+
}
|
|
96
|
+
setEditingIndex(null)
|
|
97
|
+
setIsAdding(false)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="flex flex-col gap-3">
|
|
102
|
+
{selected.length > 0 && (
|
|
103
|
+
<ul className="flex flex-col gap-2">
|
|
104
|
+
{selected.map((ot, i) => {
|
|
105
|
+
const optionType = allOptionTypes.find((t) => t.id === ot.id)
|
|
106
|
+
const isEditing = expandedIndex === i
|
|
107
|
+
if (isEditing && optionType) {
|
|
108
|
+
return (
|
|
109
|
+
<li key={ot.id} className="rounded-lg border border-border-subtle p-3">
|
|
110
|
+
<OptionPicker
|
|
111
|
+
optionType={optionType}
|
|
112
|
+
initialValues={ot.values}
|
|
113
|
+
onCancel={() => {
|
|
114
|
+
setEditingIndex(null)
|
|
115
|
+
if (ot.values.length === 0) {
|
|
116
|
+
setDismissedAutoExpand((prev) => new Set(prev).add(ot.id))
|
|
117
|
+
}
|
|
118
|
+
}}
|
|
119
|
+
onSave={(values) =>
|
|
120
|
+
upsertAt(i, {
|
|
121
|
+
id: optionType.id,
|
|
122
|
+
name: optionType.name,
|
|
123
|
+
label: optionType.label,
|
|
124
|
+
position: ot.position,
|
|
125
|
+
values,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
/>
|
|
129
|
+
</li>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
return (
|
|
133
|
+
<li
|
|
134
|
+
key={ot.id}
|
|
135
|
+
className="flex items-center gap-3 rounded-lg border border-border-subtle px-3 py-2"
|
|
136
|
+
>
|
|
137
|
+
<div className="min-w-0 flex-1">
|
|
138
|
+
<div className="text-sm font-medium">{ot.label}</div>
|
|
139
|
+
<div className="mt-1 flex flex-wrap gap-1">
|
|
140
|
+
{ot.values.map((v) => (
|
|
141
|
+
<Badge key={v.name} variant="secondary">
|
|
142
|
+
{v.label ?? v.name}
|
|
143
|
+
</Badge>
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
<Button
|
|
148
|
+
type="button"
|
|
149
|
+
variant="ghost"
|
|
150
|
+
size="icon-sm"
|
|
151
|
+
aria-label={t('admin.actions.edit')}
|
|
152
|
+
onClick={() => setEditingIndex(i)}
|
|
153
|
+
>
|
|
154
|
+
<PencilIcon />
|
|
155
|
+
</Button>
|
|
156
|
+
<Button
|
|
157
|
+
type="button"
|
|
158
|
+
variant="ghost"
|
|
159
|
+
size="icon-sm"
|
|
160
|
+
aria-label={t('admin.actions.remove')}
|
|
161
|
+
onClick={() => removeAt(i)}
|
|
162
|
+
>
|
|
163
|
+
<XIcon />
|
|
164
|
+
</Button>
|
|
165
|
+
</li>
|
|
166
|
+
)
|
|
167
|
+
})}
|
|
168
|
+
</ul>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{isAdding ? (
|
|
172
|
+
<div className="rounded-lg border border-border-subtle p-3">
|
|
173
|
+
<AddOptionForm
|
|
174
|
+
availableTypes={availableTypes}
|
|
175
|
+
allOptionTypes={allOptionTypes}
|
|
176
|
+
onCancel={() => setIsAdding(false)}
|
|
177
|
+
onSave={(payload) => upsertAt(null, payload)}
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
) : (
|
|
181
|
+
<div>
|
|
182
|
+
<Button type="button" variant="outline" size="sm" onClick={() => setIsAdding(true)}>
|
|
183
|
+
<PlusIcon />
|
|
184
|
+
{t('admin.products.variants.add_option')}
|
|
185
|
+
</Button>
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Add-option form: picks an option type (or creates a new one), then values
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
interface AddOptionFormProps {
|
|
197
|
+
availableTypes: OptionType[]
|
|
198
|
+
allOptionTypes: OptionType[]
|
|
199
|
+
onSave: (payload: SelectedOptionType) => void
|
|
200
|
+
onCancel: () => void
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function AddOptionForm({ availableTypes, allOptionTypes, onSave, onCancel }: AddOptionFormProps) {
|
|
204
|
+
const { t } = useTranslation()
|
|
205
|
+
const [pickedTypeId, setPickedTypeId] = useState<string>('')
|
|
206
|
+
const [creatingType, setCreatingType] = useState(false)
|
|
207
|
+
const createOptionType = useCreateOptionType()
|
|
208
|
+
const canCreateOptionType = Boolean(getApiClient().optionTypes?.create)
|
|
209
|
+
|
|
210
|
+
const picked =
|
|
211
|
+
allOptionTypes.find((ot) => ot.id === pickedTypeId) ??
|
|
212
|
+
availableTypes.find((ot) => ot.id === pickedTypeId)
|
|
213
|
+
|
|
214
|
+
if (creatingType) {
|
|
215
|
+
return (
|
|
216
|
+
<CreateOptionTypeInline
|
|
217
|
+
onCancel={() => setCreatingType(false)}
|
|
218
|
+
onCreated={(created) => {
|
|
219
|
+
setPickedTypeId(created.id)
|
|
220
|
+
setCreatingType(false)
|
|
221
|
+
}}
|
|
222
|
+
isPending={createOptionType.isPending}
|
|
223
|
+
createMutation={createOptionType.mutateAsync}
|
|
224
|
+
/>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!picked) {
|
|
229
|
+
return (
|
|
230
|
+
<div className="flex flex-col gap-3">
|
|
231
|
+
<Field>
|
|
232
|
+
<FieldLabel>{t('admin.products.variants.option_type_label')}</FieldLabel>
|
|
233
|
+
<Select value={pickedTypeId} onValueChange={setPickedTypeId}>
|
|
234
|
+
<SelectTrigger className="w-full">
|
|
235
|
+
<SelectValue placeholder={t('admin.products.variants.option_type_placeholder')}>
|
|
236
|
+
{(v) =>
|
|
237
|
+
availableTypes.find((ot) => ot.id === v)?.label ??
|
|
238
|
+
t('admin.products.variants.option_type_placeholder')
|
|
239
|
+
}
|
|
240
|
+
</SelectValue>
|
|
241
|
+
</SelectTrigger>
|
|
242
|
+
<SelectContent>
|
|
243
|
+
{availableTypes.map((ot) => (
|
|
244
|
+
<SelectItem key={ot.id} value={ot.id}>
|
|
245
|
+
{ot.label}
|
|
246
|
+
</SelectItem>
|
|
247
|
+
))}
|
|
248
|
+
</SelectContent>
|
|
249
|
+
</Select>
|
|
250
|
+
</Field>
|
|
251
|
+
<div className="flex items-center justify-end gap-2">
|
|
252
|
+
{/* Option types are the store's own vocabulary, so only a panel whose
|
|
253
|
+
client can write them offers to add one. A seller picks from what
|
|
254
|
+
the marketplace already defines
|
|
255
|
+
(docs/plans/6.0-seller-product-submission.md). */}
|
|
256
|
+
{canCreateOptionType && (
|
|
257
|
+
<Button
|
|
258
|
+
type="button"
|
|
259
|
+
variant="ghost"
|
|
260
|
+
size="sm"
|
|
261
|
+
className="mr-auto"
|
|
262
|
+
onClick={() => setCreatingType(true)}
|
|
263
|
+
>
|
|
264
|
+
<PlusIcon />
|
|
265
|
+
{t('admin.products.variants.create_option_type')}
|
|
266
|
+
</Button>
|
|
267
|
+
)}
|
|
268
|
+
<Button type="button" variant="outline" size="sm" onClick={onCancel}>
|
|
269
|
+
{t('admin.actions.cancel')}
|
|
270
|
+
</Button>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<OptionPicker
|
|
278
|
+
optionType={picked}
|
|
279
|
+
initialValues={[]}
|
|
280
|
+
onCancel={onCancel}
|
|
281
|
+
onSave={(values) =>
|
|
282
|
+
onSave({
|
|
283
|
+
id: picked.id,
|
|
284
|
+
name: picked.name,
|
|
285
|
+
label: picked.label,
|
|
286
|
+
position: 0, // overridden by parent on append
|
|
287
|
+
values,
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
/>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// Option-type picker + values multi-select with inline value creation
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
interface OptionPickerProps {
|
|
299
|
+
optionType: OptionType
|
|
300
|
+
initialValues: { name: string; label?: string }[]
|
|
301
|
+
onSave: (values: { name: string; label?: string }[]) => void
|
|
302
|
+
onCancel: () => void
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function OptionPicker({ optionType, initialValues, onSave, onCancel }: OptionPickerProps) {
|
|
306
|
+
const { t } = useTranslation()
|
|
307
|
+
const updateOptionType = useUpdateOptionType(optionType.id)
|
|
308
|
+
// Adding a value to an option type edits the store's own vocabulary, so it
|
|
309
|
+
// needs the write the create button needs — a panel that can list types but
|
|
310
|
+
// not update them would render an action whose mutation has nowhere to go.
|
|
311
|
+
const canAddValue = Boolean(getApiClient().optionTypes?.update)
|
|
312
|
+
const [pickedNames, setPickedNames] = useState<Set<string>>(
|
|
313
|
+
() => new Set(initialValues.map((v) => v.name)),
|
|
314
|
+
)
|
|
315
|
+
const [creatingValue, setCreatingValue] = useState(false)
|
|
316
|
+
|
|
317
|
+
const available = optionType.option_values ?? []
|
|
318
|
+
|
|
319
|
+
const toggle = (name: string) => {
|
|
320
|
+
setPickedNames((prev) => {
|
|
321
|
+
const next = new Set(prev)
|
|
322
|
+
if (next.has(name)) next.delete(name)
|
|
323
|
+
else next.add(name)
|
|
324
|
+
return next
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const handleSave = () => {
|
|
329
|
+
const ordered = available.filter((v) => pickedNames.has(v.name))
|
|
330
|
+
onSave(ordered.map((v) => ({ name: v.name, label: v.label })))
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const handleCreateValue = async (name: string, label: string) => {
|
|
334
|
+
const newValue = { name, label }
|
|
335
|
+
// OptionTypeUpdateParams.option_values replaces the full set — preserve
|
|
336
|
+
// existing values and append the new one.
|
|
337
|
+
const nextValues = [
|
|
338
|
+
...available.map((v) => ({
|
|
339
|
+
id: v.id,
|
|
340
|
+
name: v.name,
|
|
341
|
+
label: v.label,
|
|
342
|
+
position: v.position,
|
|
343
|
+
color_code: v.color_code,
|
|
344
|
+
})),
|
|
345
|
+
newValue,
|
|
346
|
+
]
|
|
347
|
+
try {
|
|
348
|
+
await updateOptionType.mutateAsync({ option_values: nextValues })
|
|
349
|
+
setPickedNames((prev) => new Set(prev).add(name))
|
|
350
|
+
setCreatingValue(false)
|
|
351
|
+
} catch (err) {
|
|
352
|
+
const message =
|
|
353
|
+
err instanceof Error
|
|
354
|
+
? err.message
|
|
355
|
+
: t('admin.products.variants.errors.failed_to_create_value')
|
|
356
|
+
toastManager.add({ type: 'error', title: message })
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (creatingValue) {
|
|
361
|
+
return (
|
|
362
|
+
<CreateOptionValueInline
|
|
363
|
+
onCancel={() => setCreatingValue(false)}
|
|
364
|
+
onCreate={handleCreateValue}
|
|
365
|
+
isPending={updateOptionType.isPending}
|
|
366
|
+
/>
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return (
|
|
371
|
+
<div className="flex flex-col gap-3">
|
|
372
|
+
<div className="flex items-center justify-between">
|
|
373
|
+
<div className="text-sm font-medium">{optionType.label}</div>
|
|
374
|
+
</div>
|
|
375
|
+
<div className="flex flex-wrap gap-2">
|
|
376
|
+
{available.map((v) => {
|
|
377
|
+
const isPicked = pickedNames.has(v.name)
|
|
378
|
+
return (
|
|
379
|
+
<button
|
|
380
|
+
key={v.id}
|
|
381
|
+
type="button"
|
|
382
|
+
onClick={() => toggle(v.name)}
|
|
383
|
+
aria-pressed={isPicked}
|
|
384
|
+
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors ${
|
|
385
|
+
isPicked
|
|
386
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
387
|
+
: 'border-border bg-background hover:bg-accent'
|
|
388
|
+
}`}
|
|
389
|
+
>
|
|
390
|
+
{isPicked && <CheckIcon className="size-3" />}
|
|
391
|
+
{v.label || v.name}
|
|
392
|
+
</button>
|
|
393
|
+
)
|
|
394
|
+
})}
|
|
395
|
+
{canAddValue && (
|
|
396
|
+
<button
|
|
397
|
+
type="button"
|
|
398
|
+
onClick={() => setCreatingValue(true)}
|
|
399
|
+
className="inline-flex items-center gap-1.5 rounded-full border border-dashed border-border bg-background px-3 py-1 text-xs hover:bg-accent"
|
|
400
|
+
>
|
|
401
|
+
<PlusIcon className="size-3" />
|
|
402
|
+
{t('admin.products.variants.create_value')}
|
|
403
|
+
</button>
|
|
404
|
+
)}
|
|
405
|
+
</div>
|
|
406
|
+
<div className="flex items-center justify-end gap-2">
|
|
407
|
+
<Button type="button" variant="outline" size="sm" onClick={onCancel}>
|
|
408
|
+
{t('admin.actions.cancel')}
|
|
409
|
+
</Button>
|
|
410
|
+
<Button type="button" size="sm" onClick={handleSave} disabled={pickedNames.size === 0}>
|
|
411
|
+
{t('admin.actions.done')}
|
|
412
|
+
</Button>
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
// Inline: create a new option type
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
interface CreateOptionTypeInlineProps {
|
|
423
|
+
onCancel: () => void
|
|
424
|
+
onCreated: (optionType: OptionType) => void
|
|
425
|
+
isPending: boolean
|
|
426
|
+
createMutation: (params: { name: string; label: string; kind: string }) => Promise<OptionType>
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function CreateOptionTypeInline({
|
|
430
|
+
onCancel,
|
|
431
|
+
onCreated,
|
|
432
|
+
isPending,
|
|
433
|
+
createMutation,
|
|
434
|
+
}: CreateOptionTypeInlineProps) {
|
|
435
|
+
const { t } = useTranslation()
|
|
436
|
+
const [label, setLabel] = useState('')
|
|
437
|
+
const [name, setName] = useState('')
|
|
438
|
+
const [touchedName, setTouchedName] = useState(false)
|
|
439
|
+
|
|
440
|
+
const submit = async () => {
|
|
441
|
+
const finalName = (name || label).trim().toLowerCase().replace(/\s+/g, '_')
|
|
442
|
+
if (!label.trim() || !finalName) return
|
|
443
|
+
try {
|
|
444
|
+
const created = await createMutation({
|
|
445
|
+
name: finalName,
|
|
446
|
+
label: label.trim(),
|
|
447
|
+
kind: 'dropdown',
|
|
448
|
+
})
|
|
449
|
+
onCreated(created)
|
|
450
|
+
} catch (err) {
|
|
451
|
+
const message =
|
|
452
|
+
err instanceof Error
|
|
453
|
+
? err.message
|
|
454
|
+
: t('admin.products.variants.errors.failed_to_create_option_type')
|
|
455
|
+
toastManager.add({ type: 'error', title: message })
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return (
|
|
460
|
+
<div className="flex flex-col gap-3">
|
|
461
|
+
<div className="text-sm font-medium">
|
|
462
|
+
{t('admin.products.variants.create_option_type_title')}
|
|
463
|
+
</div>
|
|
464
|
+
<Field>
|
|
465
|
+
<FieldLabel htmlFor="new-option-type-label">
|
|
466
|
+
{t('admin.fields.option_type.label.label')}
|
|
467
|
+
</FieldLabel>
|
|
468
|
+
<Input
|
|
469
|
+
id="new-option-type-label"
|
|
470
|
+
value={label}
|
|
471
|
+
onChange={(e) => {
|
|
472
|
+
setLabel(e.target.value)
|
|
473
|
+
if (!touchedName) {
|
|
474
|
+
setName(e.target.value.trim().toLowerCase().replace(/\s+/g, '_'))
|
|
475
|
+
}
|
|
476
|
+
}}
|
|
477
|
+
placeholder={t('admin.fields.option_type.label.placeholder')}
|
|
478
|
+
/>
|
|
479
|
+
</Field>
|
|
480
|
+
<Field>
|
|
481
|
+
<FieldLabel htmlFor="new-option-type-name">
|
|
482
|
+
{t('admin.fields.option_type.name.label')}
|
|
483
|
+
</FieldLabel>
|
|
484
|
+
<Input
|
|
485
|
+
id="new-option-type-name"
|
|
486
|
+
value={name}
|
|
487
|
+
onChange={(e) => {
|
|
488
|
+
setTouchedName(true)
|
|
489
|
+
setName(e.target.value)
|
|
490
|
+
}}
|
|
491
|
+
placeholder={t('admin.fields.option_type.name.placeholder')}
|
|
492
|
+
/>
|
|
493
|
+
</Field>
|
|
494
|
+
<div className="flex items-center justify-end gap-2">
|
|
495
|
+
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={isPending}>
|
|
496
|
+
{t('admin.actions.cancel')}
|
|
497
|
+
</Button>
|
|
498
|
+
<Button type="button" size="sm" onClick={submit} disabled={isPending || !label.trim()}>
|
|
499
|
+
{isPending ? t('admin.actions.creating') : t('admin.actions.create')}
|
|
500
|
+
</Button>
|
|
501
|
+
</div>
|
|
502
|
+
</div>
|
|
503
|
+
)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
// Inline: create a new option value on the picked option type
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
interface CreateOptionValueInlineProps {
|
|
511
|
+
onCancel: () => void
|
|
512
|
+
onCreate: (name: string, label: string) => Promise<void>
|
|
513
|
+
isPending: boolean
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function CreateOptionValueInline({ onCancel, onCreate, isPending }: CreateOptionValueInlineProps) {
|
|
517
|
+
const { t } = useTranslation()
|
|
518
|
+
const [label, setLabel] = useState('')
|
|
519
|
+
const [name, setName] = useState('')
|
|
520
|
+
const [touchedName, setTouchedName] = useState(false)
|
|
521
|
+
|
|
522
|
+
const submit = async () => {
|
|
523
|
+
const finalName = (name || label).trim().toLowerCase().replace(/\s+/g, '_')
|
|
524
|
+
if (!label.trim() || !finalName) return
|
|
525
|
+
await onCreate(finalName, label.trim())
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return (
|
|
529
|
+
<div className="flex flex-col gap-3">
|
|
530
|
+
<div className="text-sm font-medium">{t('admin.products.variants.create_value_title')}</div>
|
|
531
|
+
<Field>
|
|
532
|
+
<FieldLabel htmlFor="new-option-value-label">
|
|
533
|
+
{t('admin.fields.option_value.label.label')}
|
|
534
|
+
</FieldLabel>
|
|
535
|
+
<Input
|
|
536
|
+
id="new-option-value-label"
|
|
537
|
+
value={label}
|
|
538
|
+
onChange={(e) => {
|
|
539
|
+
setLabel(e.target.value)
|
|
540
|
+
if (!touchedName) {
|
|
541
|
+
setName(e.target.value.trim().toLowerCase().replace(/\s+/g, '_'))
|
|
542
|
+
}
|
|
543
|
+
}}
|
|
544
|
+
placeholder={t('admin.fields.option_value.label.placeholder')}
|
|
545
|
+
/>
|
|
546
|
+
</Field>
|
|
547
|
+
<Field>
|
|
548
|
+
<FieldLabel htmlFor="new-option-value-name">
|
|
549
|
+
{t('admin.fields.option_value.name.label')}
|
|
550
|
+
</FieldLabel>
|
|
551
|
+
<Input
|
|
552
|
+
id="new-option-value-name"
|
|
553
|
+
value={name}
|
|
554
|
+
onChange={(e) => {
|
|
555
|
+
setTouchedName(true)
|
|
556
|
+
setName(e.target.value)
|
|
557
|
+
}}
|
|
558
|
+
placeholder={t('admin.fields.option_value.name.placeholder')}
|
|
559
|
+
/>
|
|
560
|
+
</Field>
|
|
561
|
+
<div className="flex items-center justify-end gap-2">
|
|
562
|
+
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={isPending}>
|
|
563
|
+
{t('admin.actions.cancel')}
|
|
564
|
+
</Button>
|
|
565
|
+
<Button type="button" size="sm" onClick={submit} disabled={isPending || !label.trim()}>
|
|
566
|
+
{isPending ? t('admin.actions.creating') : t('admin.actions.create')}
|
|
567
|
+
</Button>
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
)
|
|
571
|
+
}
|