@vendure/dashboard 3.4.2-master-202508290230 → 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.
Files changed (31) hide show
  1. package/package.json +4 -4
  2. package/src/app/routes/_authenticated/_channels/channels_.$id.tsx +3 -0
  3. package/src/app/routes/_authenticated/_collections/components/collection-bulk-actions.tsx +7 -7
  4. package/src/app/routes/_authenticated/_facets/components/facet-bulk-actions.tsx +3 -3
  5. package/src/app/routes/_authenticated/_payment-methods/components/payment-method-bulk-actions.tsx +2 -2
  6. package/src/app/routes/_authenticated/_product-variants/components/product-variant-bulk-actions.tsx +4 -4
  7. package/src/app/routes/_authenticated/_products/components/add-option-group-dialog.tsx +127 -0
  8. package/src/app/routes/_authenticated/_products/components/add-product-variant-dialog.tsx +41 -39
  9. package/src/app/routes/_authenticated/_products/components/create-product-options-dialog.tsx +1 -33
  10. package/src/app/routes/_authenticated/_products/components/create-product-variants-dialog.tsx +7 -42
  11. package/src/app/routes/_authenticated/_products/components/create-product-variants.tsx +38 -134
  12. package/src/app/routes/_authenticated/_products/components/option-groups-editor.tsx +180 -0
  13. package/src/app/routes/_authenticated/_products/components/option-value-input.tsx +9 -39
  14. package/src/app/routes/_authenticated/_products/components/product-bulk-actions.tsx +2 -2
  15. package/src/app/routes/_authenticated/_products/products.graphql.ts +136 -0
  16. package/src/app/routes/_authenticated/_products/products_.$id.tsx +9 -9
  17. package/src/app/routes/_authenticated/_products/products_.$id_.variants.tsx +405 -0
  18. package/src/app/routes/_authenticated/_promotions/components/promotion-bulk-actions.tsx +2 -2
  19. package/src/app/routes/_authenticated/_shipping-methods/components/shipping-method-bulk-actions.tsx +2 -2
  20. package/src/app/routes/_authenticated/_stock-locations/components/stock-location-bulk-actions.tsx +3 -3
  21. package/src/lib/components/data-input/rich-text-input.tsx +8 -4
  22. package/src/lib/components/layout/channel-switcher.tsx +27 -6
  23. package/src/lib/components/layout/manage-languages-dialog.tsx +2 -2
  24. package/src/lib/components/shared/asset/asset-gallery.tsx +20 -2
  25. package/src/lib/components/shared/asset/asset-picker-dialog.tsx +5 -5
  26. package/src/lib/components/shared/assign-to-channel-dialog.tsx +2 -2
  27. package/src/lib/components/shared/remove-from-channel-bulk-action.tsx +2 -2
  28. package/src/lib/graphql/api.ts +3 -1
  29. package/src/lib/hooks/use-permissions.ts +4 -4
  30. package/src/lib/providers/auth.tsx +8 -0
  31. 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
- <AddProductVariantDialog
159
- productId={params.id}
160
- onSuccess={() => {
161
- refreshRef.current?.();
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 { selectedChannel } = useChannel();
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: selectedChannel?.id,
55
+ channelId: activeChannel?.id,
56
56
  })}
57
57
  />
58
58
  );
@@ -43,7 +43,7 @@ export const RemoveShippingMethodsFromChannelBulkAction: BulkActionComponent<any
43
43
  selection,
44
44
  table,
45
45
  }) => {
46
- const { selectedChannel } = useChannel();
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: selectedChannel?.id,
57
+ channelId: activeChannel?.id,
58
58
  })}
59
59
  />
60
60
  );
@@ -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 { selectedChannel } = useChannel();
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: selectedChannel?.id,
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
- onChange(editor.getHTML());
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, selectedChannel, setSelectedChannel } = useChannel();
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 = selectedChannel || activeChannel;
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 className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
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={() => setSelectedChannel(channel.id)}
128
+ onClick={() => setActiveChannel(channel.id)}
113
129
  className="gap-2 p-2"
114
130
  >
115
- <div className="flex size-8 items-center justify-center rounded border">
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, selectedChannel } = useChannel();
99
+ const { activeChannel } = useChannel();
100
100
  const { hasPermissions } = usePermissions();
101
101
  const queryClient = useQueryClient();
102
102
 
103
- const displayChannel = selectedChannel || activeChannel;
103
+ const displayChannel = activeChannel;
104
104
 
105
105
  // Permission checks
106
106
  const canReadGlobalSettings = hasPermissions(['ReadSettings']) || hasPermissions(['ReadGlobalSettings']);