@vendure/dashboard 3.4.2-master-202509020230 → 3.4.2-master-202509030226
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 +4 -4
- package/src/app/routes/_authenticated/_channels/channels_.$id.tsx +3 -0
- package/src/app/routes/_authenticated/_collections/components/collection-bulk-actions.tsx +7 -7
- package/src/app/routes/_authenticated/_facets/components/facet-bulk-actions.tsx +3 -3
- package/src/app/routes/_authenticated/_payment-methods/components/payment-method-bulk-actions.tsx +2 -2
- package/src/app/routes/_authenticated/_product-variants/components/product-variant-bulk-actions.tsx +4 -4
- package/src/app/routes/_authenticated/_products/components/add-option-group-dialog.tsx +127 -0
- package/src/app/routes/_authenticated/_products/components/add-product-variant-dialog.tsx +41 -39
- package/src/app/routes/_authenticated/_products/components/create-product-options-dialog.tsx +1 -33
- package/src/app/routes/_authenticated/_products/components/create-product-variants-dialog.tsx +7 -42
- package/src/app/routes/_authenticated/_products/components/create-product-variants.tsx +38 -134
- package/src/app/routes/_authenticated/_products/components/option-groups-editor.tsx +180 -0
- package/src/app/routes/_authenticated/_products/components/option-value-input.tsx +9 -39
- package/src/app/routes/_authenticated/_products/components/product-bulk-actions.tsx +2 -2
- package/src/app/routes/_authenticated/_products/products.graphql.ts +136 -0
- package/src/app/routes/_authenticated/_products/products_.$id.tsx +9 -9
- package/src/app/routes/_authenticated/_products/products_.$id_.variants.tsx +405 -0
- package/src/app/routes/_authenticated/_promotions/components/promotion-bulk-actions.tsx +2 -2
- package/src/app/routes/_authenticated/_shipping-methods/components/shipping-method-bulk-actions.tsx +2 -2
- package/src/app/routes/_authenticated/_stock-locations/components/stock-location-bulk-actions.tsx +3 -3
- package/src/lib/components/data-input/rich-text-input.tsx +8 -4
- package/src/lib/components/layout/channel-switcher.tsx +27 -6
- package/src/lib/components/layout/manage-languages-dialog.tsx +2 -2
- package/src/lib/components/shared/asset/asset-gallery.tsx +20 -2
- package/src/lib/components/shared/asset/asset-picker-dialog.tsx +5 -5
- package/src/lib/components/shared/assign-to-channel-dialog.tsx +2 -2
- package/src/lib/components/shared/remove-from-channel-bulk-action.tsx +2 -2
- package/src/lib/graphql/api.ts +3 -1
- package/src/lib/hooks/use-permissions.ts +4 -4
- package/src/lib/providers/auth.tsx +8 -0
- package/src/lib/providers/channel-provider.tsx +48 -57
|
@@ -23,10 +23,10 @@ import {
|
|
|
23
23
|
import { detailPageRouteLoader } from '@/vdb/framework/page/detail-page-route-loader.js';
|
|
24
24
|
import { useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
|
|
25
25
|
import { Trans, useLingui } from '@/vdb/lib/trans.js';
|
|
26
|
-
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
26
|
+
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
|
|
27
|
+
import { PlusIcon } from 'lucide-react';
|
|
27
28
|
import { useRef } from 'react';
|
|
28
29
|
import { toast } from 'sonner';
|
|
29
|
-
import { AddProductVariantDialog } from './components/add-product-variant-dialog.js';
|
|
30
30
|
import { CreateProductVariantsDialog } from './components/create-product-variants-dialog.js';
|
|
31
31
|
import { ProductVariantsTable } from './components/product-variants-table.js';
|
|
32
32
|
import { createProductDocument, productDetailDocument, updateProductDocument } from './products.graphql.js';
|
|
@@ -154,13 +154,13 @@ function ProductDetailPage() {
|
|
|
154
154
|
}}
|
|
155
155
|
fromProductDetailPage={true}
|
|
156
156
|
/>
|
|
157
|
-
<div className="mt-4">
|
|
158
|
-
<
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
157
|
+
<div className="mt-4 flex gap-2">
|
|
158
|
+
<Button asChild variant="outline">
|
|
159
|
+
<Link to="./variants">
|
|
160
|
+
<PlusIcon className="mr-2 h-4 w-4" />
|
|
161
|
+
<Trans>Manage variants</Trans>
|
|
162
|
+
</Link>
|
|
163
|
+
</Button>
|
|
164
164
|
</div>
|
|
165
165
|
</PageBlock>
|
|
166
166
|
)}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { ErrorPage } from '@/vdb/components/shared/error-page.js';
|
|
2
|
+
import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
|
|
3
|
+
import { Badge } from '@/vdb/components/ui/badge.js';
|
|
4
|
+
import { Button } from '@/vdb/components/ui/button.js';
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
DialogTrigger,
|
|
12
|
+
} from '@/vdb/components/ui/dialog.js';
|
|
13
|
+
import { Form } from '@/vdb/components/ui/form.js';
|
|
14
|
+
import { Input } from '@/vdb/components/ui/input.js';
|
|
15
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/vdb/components/ui/select.js';
|
|
16
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/vdb/components/ui/table.js';
|
|
17
|
+
import { Page, PageBlock, PageLayout, PageTitle } from '@/vdb/framework/layout-engine/page-layout.js';
|
|
18
|
+
import { api } from '@/vdb/graphql/api.js';
|
|
19
|
+
import { ResultOf } from '@/vdb/graphql/graphql.js';
|
|
20
|
+
import { Trans, useLingui } from '@/vdb/lib/trans.js';
|
|
21
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
22
|
+
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
23
|
+
import { createFileRoute } from '@tanstack/react-router';
|
|
24
|
+
import { Plus, Save, Trash2 } from 'lucide-react';
|
|
25
|
+
import { useState } from 'react';
|
|
26
|
+
import { useForm } from 'react-hook-form';
|
|
27
|
+
import { toast } from 'sonner';
|
|
28
|
+
import * as z from 'zod';
|
|
29
|
+
import { AddOptionGroupDialog } from './components/add-option-group-dialog.js';
|
|
30
|
+
import { AddProductVariantDialog } from './components/add-product-variant-dialog.js';
|
|
31
|
+
import {
|
|
32
|
+
createProductOptionDocument,
|
|
33
|
+
deleteProductVariantDocument,
|
|
34
|
+
productDetailWithVariantsDocument,
|
|
35
|
+
removeOptionGroupFromProductDocument,
|
|
36
|
+
updateProductVariantDocument,
|
|
37
|
+
} from './products.graphql.js';
|
|
38
|
+
|
|
39
|
+
const pageId = 'manage-product-variants';
|
|
40
|
+
const getQueryKey = (id: string) => ['DetailPage', 'product', id, 'manage-variants'];
|
|
41
|
+
|
|
42
|
+
export const Route = createFileRoute('/_authenticated/_products/products_/$id_/variants')({
|
|
43
|
+
component: ManageProductVariants,
|
|
44
|
+
loader: async ({ context, params, location }) => {
|
|
45
|
+
if (!params.id) {
|
|
46
|
+
throw new Error('ID param is required');
|
|
47
|
+
}
|
|
48
|
+
const result = await context.queryClient.ensureQueryData({
|
|
49
|
+
queryKey: getQueryKey(params.id),
|
|
50
|
+
queryFn: () => api.query(productDetailWithVariantsDocument, { id: params.id }),
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
breadcrumb: [
|
|
54
|
+
{ path: '/products', label: 'Products' },
|
|
55
|
+
{ path: `/products/${params.id}`, label: result.product?.name },
|
|
56
|
+
<Trans>Manage variants</Trans>,
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
errorComponent: ({ error }) => <ErrorPage message={error.message} />,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const optionGroupSchema = z.object({
|
|
64
|
+
name: z.string().min(1, 'Option group name is required'),
|
|
65
|
+
values: z.array(z.string()).min(1, 'At least one option value is required'),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const addOptionValueSchema = z.object({
|
|
69
|
+
name: z.string().min(1, 'Option value name is required'),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
type AddOptionValueFormValues = z.infer<typeof addOptionValueSchema>;
|
|
73
|
+
type Variant = NonNullable<ResultOf<typeof productDetailWithVariantsDocument>['product']>['variants'][0];
|
|
74
|
+
|
|
75
|
+
function AddOptionValueDialog({
|
|
76
|
+
groupId,
|
|
77
|
+
groupName,
|
|
78
|
+
onSuccess,
|
|
79
|
+
}: Readonly<{
|
|
80
|
+
groupId: string;
|
|
81
|
+
groupName: string;
|
|
82
|
+
onSuccess?: () => void;
|
|
83
|
+
}>) {
|
|
84
|
+
const [open, setOpen] = useState(false);
|
|
85
|
+
const { i18n } = useLingui();
|
|
86
|
+
|
|
87
|
+
const form = useForm<AddOptionValueFormValues>({
|
|
88
|
+
resolver: zodResolver(addOptionValueSchema),
|
|
89
|
+
defaultValues: {
|
|
90
|
+
name: '',
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const createOptionMutation = useMutation({
|
|
95
|
+
mutationFn: api.mutate(createProductOptionDocument),
|
|
96
|
+
onSuccess: () => {
|
|
97
|
+
toast.success(i18n.t('Successfully added option value'));
|
|
98
|
+
setOpen(false);
|
|
99
|
+
form.reset();
|
|
100
|
+
onSuccess?.();
|
|
101
|
+
},
|
|
102
|
+
onError: error => {
|
|
103
|
+
toast.error(i18n.t('Failed to add option value'), {
|
|
104
|
+
description: error instanceof Error ? error.message : i18n.t('Unknown error'),
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const onSubmit = (values: AddOptionValueFormValues) => {
|
|
110
|
+
createOptionMutation.mutate({
|
|
111
|
+
input: {
|
|
112
|
+
productOptionGroupId: groupId,
|
|
113
|
+
code: values.name.toLowerCase().replace(/\s+/g, '-'),
|
|
114
|
+
translations: [
|
|
115
|
+
{
|
|
116
|
+
languageCode: 'en',
|
|
117
|
+
name: values.name,
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
126
|
+
<DialogTrigger asChild>
|
|
127
|
+
<Button size="icon" variant="ghost">
|
|
128
|
+
<Plus className="h-3 w-3" />
|
|
129
|
+
</Button>
|
|
130
|
+
</DialogTrigger>
|
|
131
|
+
<DialogContent>
|
|
132
|
+
<DialogHeader>
|
|
133
|
+
<DialogTitle>
|
|
134
|
+
<Trans>Add option value to {groupName}</Trans>
|
|
135
|
+
</DialogTitle>
|
|
136
|
+
</DialogHeader>
|
|
137
|
+
<Form {...form}>
|
|
138
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
139
|
+
<FormFieldWrapper
|
|
140
|
+
control={form.control}
|
|
141
|
+
name="name"
|
|
142
|
+
label={<Trans>Option value name</Trans>}
|
|
143
|
+
render={({ field }) => (
|
|
144
|
+
<Input {...field} placeholder={i18n.t('e.g., Red, Large, Cotton')} />
|
|
145
|
+
)}
|
|
146
|
+
/>
|
|
147
|
+
<DialogFooter>
|
|
148
|
+
<Button type="submit" disabled={createOptionMutation.isPending}>
|
|
149
|
+
<Trans>Add option value</Trans>
|
|
150
|
+
</Button>
|
|
151
|
+
</DialogFooter>
|
|
152
|
+
</form>
|
|
153
|
+
</Form>
|
|
154
|
+
</DialogContent>
|
|
155
|
+
</Dialog>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function ManageProductVariants() {
|
|
160
|
+
const { id } = Route.useParams();
|
|
161
|
+
const { i18n } = useLingui();
|
|
162
|
+
const [optionsToAddToVariant, setOptionsToAddToVariant] = useState<
|
|
163
|
+
Record<string, Record<string, string>>
|
|
164
|
+
>({});
|
|
165
|
+
|
|
166
|
+
const { data: productData, refetch } = useQuery({
|
|
167
|
+
queryFn: () => api.query(productDetailWithVariantsDocument, { id }),
|
|
168
|
+
queryKey: getQueryKey(id),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const updateVariantMutation = useMutation({
|
|
172
|
+
mutationFn: api.mutate(updateProductVariantDocument),
|
|
173
|
+
onSuccess: () => {
|
|
174
|
+
toast.success(i18n.t('Variant updated successfully'));
|
|
175
|
+
refetch();
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const deleteVariantMutation = useMutation({
|
|
180
|
+
mutationFn: api.mutate(deleteProductVariantDocument),
|
|
181
|
+
onSuccess: () => {
|
|
182
|
+
toast.success(i18n.t('Variant deleted successfully'));
|
|
183
|
+
refetch();
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const removeOptionGroupMutation = useMutation({
|
|
188
|
+
mutationFn: api.mutate(removeOptionGroupFromProductDocument),
|
|
189
|
+
onSuccess: () => {
|
|
190
|
+
toast.success(i18n.t('Option group removed'));
|
|
191
|
+
refetch();
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const setOptionToAddToVariant = (variantId: string, groupId: string, optionId: string | undefined) => {
|
|
196
|
+
if (!optionId) {
|
|
197
|
+
const updated = { ...optionsToAddToVariant };
|
|
198
|
+
if (updated[variantId]) {
|
|
199
|
+
delete updated[variantId][groupId];
|
|
200
|
+
}
|
|
201
|
+
setOptionsToAddToVariant(updated);
|
|
202
|
+
} else {
|
|
203
|
+
setOptionsToAddToVariant(prev => ({
|
|
204
|
+
...prev,
|
|
205
|
+
[variantId]: {
|
|
206
|
+
...prev[variantId],
|
|
207
|
+
[groupId]: optionId,
|
|
208
|
+
},
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const addOptionToVariant = async (variant: Variant) => {
|
|
214
|
+
const optionsToAdd = optionsToAddToVariant[variant.id];
|
|
215
|
+
if (!optionsToAdd) return;
|
|
216
|
+
|
|
217
|
+
const existingOptionIds = variant.options.map(o => o.id);
|
|
218
|
+
const newOptionIds = Object.values(optionsToAdd).filter(Boolean);
|
|
219
|
+
const allOptionIds = [...existingOptionIds, ...newOptionIds];
|
|
220
|
+
|
|
221
|
+
await updateVariantMutation.mutateAsync({
|
|
222
|
+
input: {
|
|
223
|
+
id: variant.id,
|
|
224
|
+
optionIds: allOptionIds,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
setOptionsToAddToVariant(prev => {
|
|
229
|
+
const updated = { ...prev };
|
|
230
|
+
delete updated[variant.id];
|
|
231
|
+
return updated;
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const deleteVariant = async (variant: Variant) => {
|
|
236
|
+
if (confirm(i18n.t('Are you sure you want to delete this variant?'))) {
|
|
237
|
+
await deleteVariantMutation.mutateAsync({ id: variant.id });
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const getOption = (variant: Variant, groupId: string) => {
|
|
242
|
+
return variant.options.find(o => o.groupId === groupId);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
if (!productData?.product) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<Page pageId={pageId}>
|
|
251
|
+
<PageTitle>
|
|
252
|
+
{productData.product.name} - <Trans>Manage variants</Trans>
|
|
253
|
+
</PageTitle>
|
|
254
|
+
<PageLayout>
|
|
255
|
+
<PageBlock column="main" blockId="option-groups" title={<Trans>Option Groups</Trans>}>
|
|
256
|
+
<div className="space-y-4 mb-4">
|
|
257
|
+
{productData.product.optionGroups.length === 0 ? (
|
|
258
|
+
<p className="text-sm text-muted-foreground">
|
|
259
|
+
<Trans>
|
|
260
|
+
No option groups defined yet. Add option groups to create different
|
|
261
|
+
variants of your product (e.g., Size, Color, Material)
|
|
262
|
+
</Trans>
|
|
263
|
+
</p>
|
|
264
|
+
) : (
|
|
265
|
+
productData.product.optionGroups.map(group => (
|
|
266
|
+
<div key={group.id} className="grid grid-cols-12 gap-4 items-start">
|
|
267
|
+
<div className="col-span-3">
|
|
268
|
+
<label className="text-sm font-medium">
|
|
269
|
+
<Trans>Option</Trans>
|
|
270
|
+
</label>
|
|
271
|
+
<Input value={group.name} disabled />
|
|
272
|
+
</div>
|
|
273
|
+
<div className="col-span-8">
|
|
274
|
+
<label className="text-sm font-medium">
|
|
275
|
+
<Trans>Option values</Trans>
|
|
276
|
+
</label>
|
|
277
|
+
<div className="flex flex-wrap gap-2 mt-1">
|
|
278
|
+
{group.options.map(option => (
|
|
279
|
+
<Badge key={option.id} variant="secondary">
|
|
280
|
+
{option.name}
|
|
281
|
+
</Badge>
|
|
282
|
+
))}
|
|
283
|
+
<AddOptionValueDialog
|
|
284
|
+
groupId={group.id}
|
|
285
|
+
groupName={group.name}
|
|
286
|
+
onSuccess={() => refetch()}
|
|
287
|
+
/>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
))
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
<AddOptionGroupDialog productId={id} onSuccess={() => refetch()} />
|
|
295
|
+
</PageBlock>
|
|
296
|
+
|
|
297
|
+
<PageBlock column="main" blockId="product-variants" title={<Trans>Variants</Trans>}>
|
|
298
|
+
<div className="mb-4">
|
|
299
|
+
<Table>
|
|
300
|
+
<TableHeader>
|
|
301
|
+
<TableRow>
|
|
302
|
+
<TableHead>
|
|
303
|
+
<Trans>Name</Trans>
|
|
304
|
+
</TableHead>
|
|
305
|
+
<TableHead>
|
|
306
|
+
<Trans>SKU</Trans>
|
|
307
|
+
</TableHead>
|
|
308
|
+
{productData.product.optionGroups.map(group => (
|
|
309
|
+
<TableHead key={group.id}>{group.name}</TableHead>
|
|
310
|
+
))}
|
|
311
|
+
<TableHead>
|
|
312
|
+
<Trans>Delete</Trans>
|
|
313
|
+
</TableHead>
|
|
314
|
+
</TableRow>
|
|
315
|
+
</TableHeader>
|
|
316
|
+
<TableBody>
|
|
317
|
+
{productData.product.variants.map(variant => (
|
|
318
|
+
<TableRow key={variant.id}>
|
|
319
|
+
<TableCell>{variant.name}</TableCell>
|
|
320
|
+
<TableCell>{variant.sku}</TableCell>
|
|
321
|
+
{productData.product?.optionGroups.map(group => {
|
|
322
|
+
const option = getOption(variant, group.id);
|
|
323
|
+
return (
|
|
324
|
+
<TableCell key={group.id}>
|
|
325
|
+
{option ? (
|
|
326
|
+
<Badge variant="outline">{option.name}</Badge>
|
|
327
|
+
) : (
|
|
328
|
+
<div className="flex items-center gap-2">
|
|
329
|
+
<Select
|
|
330
|
+
value={
|
|
331
|
+
optionsToAddToVariant[variant.id]?.[
|
|
332
|
+
group.id
|
|
333
|
+
] || ''
|
|
334
|
+
}
|
|
335
|
+
onValueChange={value =>
|
|
336
|
+
setOptionToAddToVariant(
|
|
337
|
+
variant.id,
|
|
338
|
+
group.id,
|
|
339
|
+
value || undefined,
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
>
|
|
343
|
+
<SelectTrigger className="w-32">
|
|
344
|
+
<SelectValue />
|
|
345
|
+
</SelectTrigger>
|
|
346
|
+
<SelectContent>
|
|
347
|
+
{group.options.map(opt => (
|
|
348
|
+
<SelectItem
|
|
349
|
+
key={opt.id}
|
|
350
|
+
value={opt.id}
|
|
351
|
+
>
|
|
352
|
+
{opt.name}
|
|
353
|
+
</SelectItem>
|
|
354
|
+
))}
|
|
355
|
+
</SelectContent>
|
|
356
|
+
</Select>
|
|
357
|
+
<Button
|
|
358
|
+
size="sm"
|
|
359
|
+
variant={
|
|
360
|
+
optionsToAddToVariant[variant.id]?.[
|
|
361
|
+
group.id
|
|
362
|
+
]
|
|
363
|
+
? 'default'
|
|
364
|
+
: 'outline'
|
|
365
|
+
}
|
|
366
|
+
disabled={
|
|
367
|
+
!optionsToAddToVariant[variant.id]?.[
|
|
368
|
+
group.id
|
|
369
|
+
]
|
|
370
|
+
}
|
|
371
|
+
onClick={() => addOptionToVariant(variant)}
|
|
372
|
+
>
|
|
373
|
+
<Save className="h-4 w-4" />
|
|
374
|
+
</Button>
|
|
375
|
+
</div>
|
|
376
|
+
)}
|
|
377
|
+
</TableCell>
|
|
378
|
+
);
|
|
379
|
+
})}
|
|
380
|
+
<TableCell>
|
|
381
|
+
<Button
|
|
382
|
+
size="sm"
|
|
383
|
+
variant="ghost"
|
|
384
|
+
onClick={() => deleteVariant(variant)}
|
|
385
|
+
>
|
|
386
|
+
<Trash2 className="h-4 w-4 text-destructive" />
|
|
387
|
+
</Button>
|
|
388
|
+
</TableCell>
|
|
389
|
+
</TableRow>
|
|
390
|
+
))}
|
|
391
|
+
</TableBody>
|
|
392
|
+
</Table>
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
<AddProductVariantDialog
|
|
396
|
+
productId={id}
|
|
397
|
+
onSuccess={() => {
|
|
398
|
+
refetch();
|
|
399
|
+
}}
|
|
400
|
+
/>
|
|
401
|
+
</PageBlock>
|
|
402
|
+
</PageLayout>
|
|
403
|
+
</Page>
|
|
404
|
+
);
|
|
405
|
+
}
|
|
@@ -41,7 +41,7 @@ export const AssignPromotionsToChannelBulkAction: BulkActionComponent<any> = ({
|
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
export const RemovePromotionsFromChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
|
|
44
|
-
const {
|
|
44
|
+
const { activeChannel } = useChannel();
|
|
45
45
|
|
|
46
46
|
return (
|
|
47
47
|
<RemoveFromChannelBulkAction
|
|
@@ -52,7 +52,7 @@ export const RemovePromotionsFromChannelBulkAction: BulkActionComponent<any> = (
|
|
|
52
52
|
requiredPermissions={['UpdatePromotion']}
|
|
53
53
|
buildInput={() => ({
|
|
54
54
|
promotionIds: selection.map(s => s.id),
|
|
55
|
-
channelId:
|
|
55
|
+
channelId: activeChannel?.id,
|
|
56
56
|
})}
|
|
57
57
|
/>
|
|
58
58
|
);
|
package/src/app/routes/_authenticated/_shipping-methods/components/shipping-method-bulk-actions.tsx
CHANGED
|
@@ -43,7 +43,7 @@ export const RemoveShippingMethodsFromChannelBulkAction: BulkActionComponent<any
|
|
|
43
43
|
selection,
|
|
44
44
|
table,
|
|
45
45
|
}) => {
|
|
46
|
-
const {
|
|
46
|
+
const { activeChannel } = useChannel();
|
|
47
47
|
|
|
48
48
|
return (
|
|
49
49
|
<RemoveFromChannelBulkAction
|
|
@@ -54,7 +54,7 @@ export const RemoveShippingMethodsFromChannelBulkAction: BulkActionComponent<any
|
|
|
54
54
|
requiredPermissions={['UpdateShippingMethod']}
|
|
55
55
|
buildInput={() => ({
|
|
56
56
|
shippingMethodIds: selection.map(s => s.id),
|
|
57
|
-
channelId:
|
|
57
|
+
channelId: activeChannel?.id,
|
|
58
58
|
})}
|
|
59
59
|
/>
|
|
60
60
|
);
|
package/src/app/routes/_authenticated/_stock-locations/components/stock-location-bulk-actions.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-channel-bulk-action.js';
|
|
2
2
|
import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
|
|
3
|
-
import { api } from '@/vdb/graphql/api.js';
|
|
4
3
|
import { BulkActionComponent } from '@/vdb/framework/extension-api/types/data-table.js';
|
|
4
|
+
import { api } from '@/vdb/graphql/api.js';
|
|
5
5
|
import { useChannel } from '@/vdb/hooks/use-channel.js';
|
|
6
6
|
import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
|
|
7
7
|
|
|
@@ -40,7 +40,7 @@ export const AssignStockLocationsToChannelBulkAction: BulkActionComponent<any> =
|
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
export const RemoveStockLocationsFromChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
|
|
43
|
-
const {
|
|
43
|
+
const { activeChannel } = useChannel();
|
|
44
44
|
|
|
45
45
|
return (
|
|
46
46
|
<RemoveFromChannelBulkAction
|
|
@@ -51,7 +51,7 @@ export const RemoveStockLocationsFromChannelBulkAction: BulkActionComponent<any>
|
|
|
51
51
|
requiredPermissions={['UpdateStockLocation']}
|
|
52
52
|
buildInput={() => ({
|
|
53
53
|
stockLocationIds: selection.map(s => s.id),
|
|
54
|
-
channelId:
|
|
54
|
+
channelId: activeChannel?.id,
|
|
55
55
|
})}
|
|
56
56
|
/>
|
|
57
57
|
);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
|
|
2
|
+
import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
|
|
2
3
|
import TextStyle from '@tiptap/extension-text-style';
|
|
3
4
|
import { BubbleMenu, Editor, EditorContent, useEditor } from '@tiptap/react';
|
|
4
5
|
import StarterKit from '@tiptap/starter-kit';
|
|
5
6
|
import { BoldIcon, ItalicIcon, StrikethroughIcon } from 'lucide-react';
|
|
6
7
|
import { useLayoutEffect, useRef } from 'react';
|
|
7
8
|
import { Button } from '../ui/button.js';
|
|
8
|
-
import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
|
|
9
9
|
|
|
10
10
|
// define your extension array
|
|
11
11
|
const extensions = [
|
|
@@ -32,11 +32,15 @@ export function RichTextInput({ value, onChange, fieldDef }: Readonly<DashboardF
|
|
|
32
32
|
},
|
|
33
33
|
extensions: extensions,
|
|
34
34
|
content: value,
|
|
35
|
-
editable: !readOnly,
|
|
35
|
+
editable: !readOnly,
|
|
36
36
|
onUpdate: ({ editor }) => {
|
|
37
37
|
if (!readOnly) {
|
|
38
38
|
isInternalUpdate.current = true;
|
|
39
|
-
|
|
39
|
+
console.log('onUpdate');
|
|
40
|
+
const newValue = editor.getHTML();
|
|
41
|
+
if (value !== newValue) {
|
|
42
|
+
onChange(newValue);
|
|
43
|
+
}
|
|
40
44
|
}
|
|
41
45
|
},
|
|
42
46
|
editorProps: {
|
|
@@ -61,7 +65,7 @@ export function RichTextInput({ value, onChange, fieldDef }: Readonly<DashboardF
|
|
|
61
65
|
// Update editor's editable state when disabled prop changes
|
|
62
66
|
useLayoutEffect(() => {
|
|
63
67
|
if (editor) {
|
|
64
|
-
editor.setEditable(!readOnly);
|
|
68
|
+
editor.setEditable(!readOnly, false);
|
|
65
69
|
}
|
|
66
70
|
}, [readOnly, editor]);
|
|
67
71
|
|
|
@@ -13,13 +13,15 @@ import {
|
|
|
13
13
|
DropdownMenuTrigger,
|
|
14
14
|
} from '@/vdb/components/ui/dropdown-menu.js';
|
|
15
15
|
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/vdb/components/ui/sidebar.js';
|
|
16
|
+
import { DEFAULT_CHANNEL_CODE } from '@/vdb/constants.js';
|
|
16
17
|
import { useChannel } from '@/vdb/hooks/use-channel.js';
|
|
17
18
|
import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
|
|
18
19
|
import { useServerConfig } from '@/vdb/hooks/use-server-config.js';
|
|
19
20
|
import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
|
|
20
21
|
import { Trans } from '@/vdb/lib/trans.js';
|
|
22
|
+
import { cn } from '@/vdb/lib/utils.js';
|
|
21
23
|
import { Link } from '@tanstack/react-router';
|
|
22
|
-
import { useState } from 'react';
|
|
24
|
+
import { useEffect, useState } from 'react';
|
|
23
25
|
import { ManageLanguagesDialog } from './manage-languages-dialog.js';
|
|
24
26
|
|
|
25
27
|
/**
|
|
@@ -44,7 +46,7 @@ function getChannelInitialsFromCode(code: string) {
|
|
|
44
46
|
|
|
45
47
|
export function ChannelSwitcher() {
|
|
46
48
|
const { isMobile } = useSidebar();
|
|
47
|
-
const { channels, activeChannel,
|
|
49
|
+
const { channels, activeChannel, setActiveChannel } = useChannel();
|
|
48
50
|
const serverConfig = useServerConfig();
|
|
49
51
|
const { formatLanguageName } = useLocalFormat();
|
|
50
52
|
const {
|
|
@@ -54,7 +56,7 @@ export function ChannelSwitcher() {
|
|
|
54
56
|
const [showManageLanguagesDialog, setShowManageLanguagesDialog] = useState(false);
|
|
55
57
|
|
|
56
58
|
// Use the selected channel if available, otherwise fall back to the active channel
|
|
57
|
-
const displayChannel =
|
|
59
|
+
const displayChannel = activeChannel || activeChannel;
|
|
58
60
|
|
|
59
61
|
// Get available languages from server config
|
|
60
62
|
const availableLanguages = serverConfig?.availableLanguages || [];
|
|
@@ -65,6 +67,16 @@ export function ChannelSwitcher() {
|
|
|
65
67
|
? [displayChannel, ...channels.filter(ch => ch.id !== displayChannel.id)]
|
|
66
68
|
: channels;
|
|
67
69
|
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (activeChannel?.availableLanguageCodes) {
|
|
72
|
+
// Ensure the current content language is a valid one for the active
|
|
73
|
+
// channel
|
|
74
|
+
if (!activeChannel.availableLanguageCodes.includes(contentLanguage as any)) {
|
|
75
|
+
setContentLanguage(activeChannel.defaultLanguageCode);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}, [activeChannel, contentLanguage]);
|
|
79
|
+
|
|
68
80
|
return (
|
|
69
81
|
<>
|
|
70
82
|
<SidebarMenu>
|
|
@@ -75,7 +87,11 @@ export function ChannelSwitcher() {
|
|
|
75
87
|
size="lg"
|
|
76
88
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
|
77
89
|
>
|
|
78
|
-
<div
|
|
90
|
+
<div
|
|
91
|
+
className={
|
|
92
|
+
'bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg'
|
|
93
|
+
}
|
|
94
|
+
>
|
|
79
95
|
<span className="truncate font-semibold text-xs uppercase">
|
|
80
96
|
{getChannelInitialsFromCode(displayChannel?.code || '')}
|
|
81
97
|
</span>
|
|
@@ -109,10 +125,15 @@ export function ChannelSwitcher() {
|
|
|
109
125
|
{orderedChannels.map((channel, index) => (
|
|
110
126
|
<div key={channel.code}>
|
|
111
127
|
<DropdownMenuItem
|
|
112
|
-
onClick={() =>
|
|
128
|
+
onClick={() => setActiveChannel(channel.id)}
|
|
113
129
|
className="gap-2 p-2"
|
|
114
130
|
>
|
|
115
|
-
<div
|
|
131
|
+
<div
|
|
132
|
+
className={cn(
|
|
133
|
+
'flex size-8 items-center justify-center rounded border',
|
|
134
|
+
channel.code === DEFAULT_CHANNEL_CODE ? 'bg-primary' : '',
|
|
135
|
+
)}
|
|
136
|
+
>
|
|
116
137
|
<span className="truncate font-semibold text-xs uppercase">
|
|
117
138
|
{getChannelInitialsFromCode(channel.code)}
|
|
118
139
|
</span>
|
|
@@ -96,11 +96,11 @@ interface ManageLanguagesDialogProps {
|
|
|
96
96
|
|
|
97
97
|
export function ManageLanguagesDialog({ open, onClose }: ManageLanguagesDialogProps) {
|
|
98
98
|
const { formatLanguageName } = useLocalFormat();
|
|
99
|
-
const { activeChannel
|
|
99
|
+
const { activeChannel } = useChannel();
|
|
100
100
|
const { hasPermissions } = usePermissions();
|
|
101
101
|
const queryClient = useQueryClient();
|
|
102
102
|
|
|
103
|
-
const displayChannel =
|
|
103
|
+
const displayChannel = activeChannel;
|
|
104
104
|
|
|
105
105
|
// Permission checks
|
|
106
106
|
const canReadGlobalSettings = hasPermissions(['ReadSettings']) || hasPermissions(['ReadGlobalSettings']);
|