@rahul_vendure/vendure-plugin-channel-product-availability 1.0.0 → 1.1.0
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/CHANGELOG.md +16 -0
- package/README.md +6 -7
- package/package.json +1 -1
- package/src/dashboard/channel-availability-block.tsx +289 -103
- package/src/dashboard/index.tsx +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Redesigned the Dashboard "Channel availability" block:
|
|
8
|
+
- each channel has a **Global / On / Off** segmented control; Global shows what it currently resolves to
|
|
9
|
+
- an "Available in X of Y channels" summary and a global-status pill
|
|
10
|
+
- callouts when the product is globally disabled but still sold somewhere, or globally enabled but hidden somewhere
|
|
11
|
+
- changes apply instantly and roll back if the server rejects them; toasts name the product and channel
|
|
12
|
+
- readable channel names ("Default channel"), a "Current" marker, and view-only rows (with a lock icon) for channels where the admin lacks update permission
|
|
13
|
+
- skeleton loading and styled permission/error states; refreshes when the product's own Enabled switch is saved
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Dashboard on Vendure 3.8: the "Unavailable" badge used `variant="secondary"`, which no longer exists in `@vendure-io/ui` 2.x and rendered unstyled. The block now uses only primitives shared by `@vendure-io/ui` 1.x and 2.x.
|
|
18
|
+
|
|
3
19
|
## 1.0.0
|
|
4
20
|
|
|
5
21
|
Initial release.
|
package/README.md
CHANGED
|
@@ -57,15 +57,14 @@ Indexes: `UNIQUE (productId, channelId)` and `(channelId, enabled)`.
|
|
|
57
57
|
|
|
58
58
|
### Dashboard
|
|
59
59
|
|
|
60
|
-
The React Dashboard extension is discovered automatically through the plugin's `dashboard` metadata. It adds a **Channel availability** block to the product detail page
|
|
60
|
+
The React Dashboard extension is discovered automatically through the plugin's `dashboard` metadata. It adds a **Channel availability** block to the product detail page:
|
|
61
61
|
|
|
62
|
-
-
|
|
63
|
-
-
|
|
64
|
-
-
|
|
62
|
+
- a **Global / On / Off** control per channel. *Global* follows the product's own Enabled switch; *On* and *Off* override it for that channel only.
|
|
63
|
+
- an "Available in X of Y channels" summary and a callout when the global switch and a channel disagree (e.g. "Disabled globally, but still sold in UK")
|
|
64
|
+
- changes apply instantly and roll back if the server rejects them
|
|
65
|
+
- channels where the admin has read but not update permission are shown as **view only**; channels the admin can't read at all are not listed
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
The backend works without the Dashboard.
|
|
67
|
+
Rendered and click-tested in light and dark themes on Vendure 3.6 (`@vendure-io/ui` 1.x). It uses only components whose API is identical in `@vendure-io/ui` 2.x (Vendure 3.8), but it has not been rendered on 3.8 yet. The backend works without the Dashboard.
|
|
69
68
|
|
|
70
69
|
## Options
|
|
71
70
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rahul_vendure/vendure-plugin-channel-product-availability",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Per-channel product enable/disable overrides for Vendure, enforced across catalog, search, cart and checkout",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./package.json": "./package.json",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2
|
-
import { api,
|
|
2
|
+
import { api, ChannelCodeLabel, Skeleton, useAuth, useChannel } from '@vendure/dashboard';
|
|
3
|
+
import { AlertTriangle, Eye, EyeOff, Globe, Info, Loader2, Lock } from 'lucide-react';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
3
5
|
import { toast } from 'sonner';
|
|
4
6
|
|
|
5
7
|
import {
|
|
@@ -8,163 +10,347 @@ import {
|
|
|
8
10
|
setProductChannelAvailabilityMutation,
|
|
9
11
|
} from './channel-availability.graphql';
|
|
10
12
|
|
|
13
|
+
// Only primitives whose API is identical in @vendure-io/ui 1.x (Vendure <=3.7) and
|
|
14
|
+
// 2.x (3.8+) are used; status pills and the segmented control are plain elements.
|
|
15
|
+
|
|
11
16
|
const UPDATE_PERMISSIONS = ['UpdateCatalog', 'UpdateProduct'];
|
|
12
17
|
|
|
18
|
+
type OverrideState = 'INHERITED' | 'ENABLED' | 'DISABLED';
|
|
19
|
+
|
|
13
20
|
type Row = {
|
|
14
21
|
channelId: string;
|
|
15
22
|
channelCode: string;
|
|
16
23
|
globalEnabled: boolean;
|
|
17
24
|
override: boolean | null;
|
|
18
|
-
overrideState:
|
|
25
|
+
overrideState: OverrideState;
|
|
19
26
|
effectiveEnabled: boolean;
|
|
20
27
|
};
|
|
21
28
|
|
|
29
|
+
type Change = { channelId: string; channelCode: string; next: OverrideState };
|
|
30
|
+
|
|
31
|
+
function cx(...classes: Array<string | false | null | undefined>) {
|
|
32
|
+
return classes.filter(Boolean).join(' ');
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
function isForbidden(error: unknown) {
|
|
23
36
|
const message = error instanceof Error ? error.message : String(error);
|
|
24
37
|
return /not currently authorized|FORBIDDEN/i.test(message);
|
|
25
38
|
}
|
|
26
39
|
|
|
40
|
+
function applyChange(row: Row, next: OverrideState): Row {
|
|
41
|
+
const override = next === 'INHERITED' ? null : next === 'ENABLED';
|
|
42
|
+
return {
|
|
43
|
+
...row,
|
|
44
|
+
override,
|
|
45
|
+
overrideState: next,
|
|
46
|
+
effectiveEnabled: override ?? row.globalEnabled,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
27
50
|
/**
|
|
28
|
-
* Per-channel availability of the product
|
|
29
|
-
*
|
|
51
|
+
* Per-channel availability of the product on the product detail page.
|
|
52
|
+
*
|
|
53
|
+
* Each control only changes the override for that one channel; the product's
|
|
30
54
|
* global "Enabled" switch is never modified here.
|
|
31
55
|
*/
|
|
32
|
-
export function ChannelAvailabilityBlock({
|
|
56
|
+
export function ChannelAvailabilityBlock({
|
|
57
|
+
productId,
|
|
58
|
+
productName,
|
|
59
|
+
globalEnabledHint,
|
|
60
|
+
}: {
|
|
61
|
+
productId: string;
|
|
62
|
+
productName?: string;
|
|
63
|
+
/** The product's saved `enabled` value; refetches when it changes. */
|
|
64
|
+
globalEnabledHint?: boolean;
|
|
65
|
+
}) {
|
|
33
66
|
const queryClient = useQueryClient();
|
|
34
67
|
const { channels } = useAuth();
|
|
35
|
-
const
|
|
68
|
+
const { activeChannel } = useChannel();
|
|
69
|
+
const queryKey = ['productChannelAvailabilities', productId, globalEnabledHint];
|
|
36
70
|
|
|
37
71
|
const { data, isLoading, error } = useQuery({
|
|
38
72
|
queryKey,
|
|
39
73
|
queryFn: () => api.query(productChannelAvailabilitiesQuery, { productId }),
|
|
40
74
|
});
|
|
41
75
|
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
76
|
+
const mutation = useMutation<unknown, unknown, Change, { previous: unknown }>({
|
|
77
|
+
mutationFn: ({ channelId, next }: Change): Promise<unknown> =>
|
|
78
|
+
next === 'INHERITED'
|
|
79
|
+
? api.mutate(clearProductChannelAvailabilityMutation, { input: { productId, channelId } })
|
|
80
|
+
: api.mutate(setProductChannelAvailabilityMutation, {
|
|
81
|
+
input: { productId, channelId, enabled: next === 'ENABLED' },
|
|
82
|
+
}),
|
|
83
|
+
// Optimistic: flip the row immediately, roll back if the server rejects it.
|
|
84
|
+
onMutate: async ({ channelId, next }) => {
|
|
85
|
+
await queryClient.cancelQueries({ queryKey });
|
|
86
|
+
const previous = queryClient.getQueryData<any>(queryKey);
|
|
87
|
+
queryClient.setQueryData<any>(queryKey, (old: any) =>
|
|
88
|
+
old
|
|
89
|
+
? {
|
|
90
|
+
...old,
|
|
91
|
+
productChannelAvailabilities: old.productChannelAvailabilities.map((r: Row) =>
|
|
92
|
+
r.channelId === channelId ? applyChange(r, next) : r,
|
|
93
|
+
),
|
|
94
|
+
}
|
|
95
|
+
: old,
|
|
96
|
+
);
|
|
97
|
+
return { previous };
|
|
98
|
+
},
|
|
99
|
+
onError: (err, { channelCode }, context) => {
|
|
100
|
+
queryClient.setQueryData(queryKey, context?.previous);
|
|
101
|
+
toast.error(`Could not update ${channelLabel(channelCode)}`, {
|
|
102
|
+
description: isForbidden(err)
|
|
103
|
+
? 'You do not have permission to change availability in this channel.'
|
|
104
|
+
: err instanceof Error
|
|
105
|
+
? err.message
|
|
106
|
+
: 'Unknown error',
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
onSuccess: (_result, { channelCode, next }) => {
|
|
110
|
+
const subject = productName ?? 'Product';
|
|
111
|
+
const where = channelLabel(channelCode);
|
|
112
|
+
toast.success(
|
|
113
|
+
next === 'INHERITED'
|
|
114
|
+
? `${subject} now follows the global status in ${where}`
|
|
115
|
+
: `${subject} is now ${next === 'ENABLED' ? 'available' : 'hidden'} in ${where}`,
|
|
116
|
+
);
|
|
117
|
+
},
|
|
118
|
+
onSettled: () => queryClient.invalidateQueries({ queryKey: ['productChannelAvailabilities', productId] }),
|
|
59
119
|
});
|
|
60
|
-
const busy = setOverride.isPending || clearOverride.isPending;
|
|
61
120
|
|
|
62
121
|
const canUpdateIn = (channelId: string) => {
|
|
63
|
-
const permissions = channels?.find(c => c.id === channelId)?.permissions ?? [];
|
|
64
|
-
return UPDATE_PERMISSIONS.some(p =>
|
|
122
|
+
const permissions = (channels?.find(c => c.id === channelId)?.permissions ?? []) as string[];
|
|
123
|
+
return UPDATE_PERMISSIONS.some(p => permissions.includes(p));
|
|
65
124
|
};
|
|
66
125
|
|
|
67
126
|
if (isLoading) {
|
|
68
127
|
return (
|
|
69
|
-
<div className="space-y-
|
|
70
|
-
<
|
|
71
|
-
<
|
|
128
|
+
<div className="space-y-3" aria-busy="true" aria-label="Loading channel availability">
|
|
129
|
+
<Skeleton className="h-4 w-40" />
|
|
130
|
+
<Skeleton className="h-[88px] w-full rounded-lg" />
|
|
131
|
+
<Skeleton className="h-[88px] w-full rounded-lg" />
|
|
72
132
|
</div>
|
|
73
133
|
);
|
|
74
134
|
}
|
|
135
|
+
|
|
75
136
|
if (error) {
|
|
137
|
+
const forbidden = isForbidden(error);
|
|
76
138
|
return (
|
|
77
|
-
<
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
139
|
+
<div
|
|
140
|
+
role="alert"
|
|
141
|
+
className="flex gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
|
|
142
|
+
>
|
|
143
|
+
{forbidden ? <Lock className="mt-0.5 h-4 w-4 shrink-0" /> : <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />}
|
|
144
|
+
<span>
|
|
145
|
+
{forbidden
|
|
146
|
+
? 'You do not have permission to view channel availability for this product.'
|
|
147
|
+
: `Could not load channel availability. ${error instanceof Error ? error.message : ''}`}
|
|
148
|
+
</span>
|
|
149
|
+
</div>
|
|
82
150
|
);
|
|
83
151
|
}
|
|
152
|
+
|
|
84
153
|
const rows = (data?.productChannelAvailabilities ?? []) as Row[];
|
|
85
154
|
if (!rows.length) {
|
|
86
155
|
return (
|
|
87
|
-
<
|
|
156
|
+
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
|
|
88
157
|
This product is not assigned to any channel you can manage.
|
|
89
|
-
</
|
|
158
|
+
</div>
|
|
90
159
|
);
|
|
91
160
|
}
|
|
92
161
|
|
|
93
162
|
const globalEnabled = rows[0].globalEnabled;
|
|
163
|
+
const availableCount = rows.filter(r => r.effectiveEnabled).length;
|
|
94
164
|
const enabledDespiteGlobal = rows.filter(r => !r.globalEnabled && r.overrideState === 'ENABLED');
|
|
165
|
+
const hiddenDespiteGlobal = rows.filter(r => r.globalEnabled && r.overrideState === 'DISABLED');
|
|
95
166
|
|
|
96
167
|
return (
|
|
97
168
|
<div className="space-y-3">
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
{enabledDespiteGlobal.length > 0 && (
|
|
103
|
-
<p className="rounded-md border border-warning-border bg-warning/10 p-2 text-xs text-warning">
|
|
104
|
-
This product is disabled globally but explicitly enabled in{' '}
|
|
105
|
-
{enabledDespiteGlobal.map(r => r.channelCode).join(', ')}. Customers there can find and buy it.
|
|
169
|
+
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
|
|
170
|
+
<p className="text-sm text-muted-foreground">
|
|
171
|
+
Available in <span className="font-semibold text-foreground">{availableCount}</span> of{' '}
|
|
172
|
+
{rows.length} channel{rows.length === 1 ? '' : 's'}
|
|
106
173
|
</p>
|
|
174
|
+
<span
|
|
175
|
+
className={cx(
|
|
176
|
+
'inline-flex items-center gap-1 whitespace-nowrap rounded-full border px-2 py-0.5 text-[11px] font-medium',
|
|
177
|
+
globalEnabled ? 'border-success/30 text-success' : 'text-muted-foreground',
|
|
178
|
+
)}
|
|
179
|
+
title="The product's own Enabled switch. Channels set to Global follow it."
|
|
180
|
+
>
|
|
181
|
+
<Globe className="h-3 w-3" />
|
|
182
|
+
Global {globalEnabled ? 'on' : 'off'}
|
|
183
|
+
</span>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{enabledDespiteGlobal.length > 0 && (
|
|
187
|
+
<Callout tone="warning">
|
|
188
|
+
Disabled globally, but still <strong>sold</strong> in{' '}
|
|
189
|
+
{joinLabels(enabledDespiteGlobal)} because of a channel override.
|
|
190
|
+
</Callout>
|
|
107
191
|
)}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
<div className="flex flex-wrap gap-1">
|
|
130
|
-
{row.overrideState !== 'ENABLED' && (
|
|
131
|
-
<Button
|
|
132
|
-
size="sm"
|
|
133
|
-
variant="outline"
|
|
134
|
-
disabled={busy}
|
|
135
|
-
onClick={() => setOverride.mutate({ channelId: row.channelId, enabled: true })}
|
|
136
|
-
>
|
|
137
|
-
Enable
|
|
138
|
-
</Button>
|
|
139
|
-
)}
|
|
140
|
-
{row.overrideState !== 'DISABLED' && (
|
|
141
|
-
<Button
|
|
142
|
-
size="sm"
|
|
143
|
-
variant="outline"
|
|
144
|
-
disabled={busy}
|
|
145
|
-
onClick={() => setOverride.mutate({ channelId: row.channelId, enabled: false })}
|
|
146
|
-
>
|
|
147
|
-
Disable
|
|
148
|
-
</Button>
|
|
149
|
-
)}
|
|
150
|
-
{row.overrideState !== 'INHERITED' && (
|
|
151
|
-
<Button
|
|
152
|
-
size="sm"
|
|
153
|
-
variant="ghost"
|
|
154
|
-
disabled={busy}
|
|
155
|
-
onClick={() => clearOverride.mutate({ channelId: row.channelId })}
|
|
156
|
-
>
|
|
157
|
-
Use global
|
|
158
|
-
</Button>
|
|
159
|
-
)}
|
|
160
|
-
</div>
|
|
161
|
-
) : (
|
|
162
|
-
<div className="text-xs text-muted-foreground">Read only</div>
|
|
163
|
-
)}
|
|
164
|
-
</li>
|
|
165
|
-
);
|
|
166
|
-
})}
|
|
192
|
+
{hiddenDespiteGlobal.length > 0 && (
|
|
193
|
+
<Callout tone="info">
|
|
194
|
+
Enabled globally, but <strong>hidden</strong> in {joinLabels(hiddenDespiteGlobal)}.
|
|
195
|
+
</Callout>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
<ul className="space-y-2">
|
|
199
|
+
{rows.map(row => (
|
|
200
|
+
<ChannelRow
|
|
201
|
+
key={row.channelId}
|
|
202
|
+
row={row}
|
|
203
|
+
isActive={row.channelId === activeChannel?.id}
|
|
204
|
+
canUpdate={canUpdateIn(row.channelId)}
|
|
205
|
+
pending={mutation.isPending && mutation.variables?.channelId === row.channelId}
|
|
206
|
+
disabled={mutation.isPending}
|
|
207
|
+
onChange={next =>
|
|
208
|
+
next !== row.overrideState &&
|
|
209
|
+
mutation.mutate({ channelId: row.channelId, channelCode: row.channelCode, next })
|
|
210
|
+
}
|
|
211
|
+
/>
|
|
212
|
+
))}
|
|
167
213
|
</ul>
|
|
168
214
|
</div>
|
|
169
215
|
);
|
|
170
216
|
}
|
|
217
|
+
|
|
218
|
+
function ChannelRow({
|
|
219
|
+
row,
|
|
220
|
+
isActive,
|
|
221
|
+
canUpdate,
|
|
222
|
+
pending,
|
|
223
|
+
disabled,
|
|
224
|
+
onChange,
|
|
225
|
+
}: {
|
|
226
|
+
row: Row;
|
|
227
|
+
isActive: boolean;
|
|
228
|
+
canUpdate: boolean;
|
|
229
|
+
pending: boolean;
|
|
230
|
+
disabled: boolean;
|
|
231
|
+
onChange: (next: OverrideState) => void;
|
|
232
|
+
}) {
|
|
233
|
+
const options: Array<{ value: OverrideState; label: string; hint: string }> = [
|
|
234
|
+
{ value: 'INHERITED', label: 'Global', hint: `Follow the global status (${row.globalEnabled ? 'on' : 'off'})` },
|
|
235
|
+
{ value: 'ENABLED', label: 'On', hint: 'Always available in this channel' },
|
|
236
|
+
{ value: 'DISABLED', label: 'Off', hint: 'Always hidden in this channel' },
|
|
237
|
+
];
|
|
238
|
+
const description =
|
|
239
|
+
row.overrideState === 'INHERITED'
|
|
240
|
+
? `Follows global (${row.globalEnabled ? 'on' : 'off'})`
|
|
241
|
+
: row.overrideState === 'ENABLED'
|
|
242
|
+
? row.globalEnabled
|
|
243
|
+
? 'Always on here'
|
|
244
|
+
: 'On here, despite global off'
|
|
245
|
+
: 'Always off here';
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<li
|
|
249
|
+
className={cx(
|
|
250
|
+
'rounded-lg border bg-card p-3 transition-colors',
|
|
251
|
+
'border-l-[3px]',
|
|
252
|
+
row.effectiveEnabled ? 'border-l-success' : 'border-l-muted-foreground/30',
|
|
253
|
+
)}
|
|
254
|
+
>
|
|
255
|
+
<div className="flex items-center justify-between gap-2">
|
|
256
|
+
<p className="min-w-0 truncate text-sm font-medium" title={row.channelCode}>
|
|
257
|
+
<ChannelCodeLabel code={row.channelCode} />
|
|
258
|
+
</p>
|
|
259
|
+
<StatusPill available={row.effectiveEnabled} pending={pending} />
|
|
260
|
+
</div>
|
|
261
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
262
|
+
{isActive && <span className="font-medium text-foreground/70">Current · </span>}
|
|
263
|
+
{description}
|
|
264
|
+
</p>
|
|
265
|
+
|
|
266
|
+
{canUpdate ? (
|
|
267
|
+
<div
|
|
268
|
+
role="radiogroup"
|
|
269
|
+
aria-label={`Availability in ${channelLabel(row.channelCode)}`}
|
|
270
|
+
className="mt-3 grid grid-cols-3 gap-0.5 rounded-md bg-muted p-0.5"
|
|
271
|
+
>
|
|
272
|
+
{options.map(option => {
|
|
273
|
+
const selected = row.overrideState === option.value;
|
|
274
|
+
return (
|
|
275
|
+
<button
|
|
276
|
+
key={option.value}
|
|
277
|
+
type="button"
|
|
278
|
+
role="radio"
|
|
279
|
+
aria-checked={selected}
|
|
280
|
+
title={option.hint}
|
|
281
|
+
disabled={disabled}
|
|
282
|
+
onClick={() => onChange(option.value)}
|
|
283
|
+
className={cx(
|
|
284
|
+
'inline-flex items-center justify-center gap-1 rounded-[5px] px-2 py-1 text-xs font-medium transition-all',
|
|
285
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
286
|
+
'disabled:cursor-not-allowed',
|
|
287
|
+
selected
|
|
288
|
+
? 'bg-background text-foreground shadow-sm'
|
|
289
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
290
|
+
disabled && !selected && 'opacity-60',
|
|
291
|
+
)}
|
|
292
|
+
>
|
|
293
|
+
{option.value === 'INHERITED' && <Globe className="h-3 w-3" />}
|
|
294
|
+
{option.value === 'ENABLED' && <Eye className="h-3 w-3" />}
|
|
295
|
+
{option.value === 'DISABLED' && <EyeOff className="h-3 w-3" />}
|
|
296
|
+
{option.label}
|
|
297
|
+
</button>
|
|
298
|
+
);
|
|
299
|
+
})}
|
|
300
|
+
</div>
|
|
301
|
+
) : (
|
|
302
|
+
<p
|
|
303
|
+
className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground"
|
|
304
|
+
title="You need UpdateCatalog or UpdateProduct permission in this channel to change it."
|
|
305
|
+
>
|
|
306
|
+
<Lock className="h-3 w-3" />
|
|
307
|
+
View only in this channel
|
|
308
|
+
</p>
|
|
309
|
+
)}
|
|
310
|
+
</li>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function StatusPill({ available, pending }: { available: boolean; pending: boolean }) {
|
|
315
|
+
return (
|
|
316
|
+
<span
|
|
317
|
+
className={cx(
|
|
318
|
+
'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium',
|
|
319
|
+
available ? 'bg-success/10 text-success' : 'bg-muted text-muted-foreground',
|
|
320
|
+
)}
|
|
321
|
+
>
|
|
322
|
+
{pending ? (
|
|
323
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
324
|
+
) : (
|
|
325
|
+
<span className={cx('h-1.5 w-1.5 rounded-full', available ? 'bg-success' : 'bg-muted-foreground/60')} />
|
|
326
|
+
)}
|
|
327
|
+
{available ? 'Available' : 'Hidden'}
|
|
328
|
+
</span>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function Callout({ tone, children }: { tone: 'warning' | 'info'; children: ReactNode }) {
|
|
333
|
+
return (
|
|
334
|
+
<div
|
|
335
|
+
className={cx(
|
|
336
|
+
'flex gap-2 rounded-lg border p-2.5 text-xs leading-relaxed',
|
|
337
|
+
tone === 'warning' ? 'border-warning/40 bg-warning/10 text-warning' : 'bg-muted/50 text-muted-foreground',
|
|
338
|
+
)}
|
|
339
|
+
>
|
|
340
|
+
{tone === 'warning' ? (
|
|
341
|
+
<AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0" />
|
|
342
|
+
) : (
|
|
343
|
+
<Info className="mt-px h-3.5 w-3.5 shrink-0" />
|
|
344
|
+
)}
|
|
345
|
+
<span>{children}</span>
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function channelLabel(code: string) {
|
|
351
|
+
return code === '__default_channel__' ? 'Default channel' : code;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function joinLabels(rows: Row[]) {
|
|
355
|
+
return rows.map(r => channelLabel(r.channelCode)).join(', ');
|
|
356
|
+
}
|
package/src/dashboard/index.tsx
CHANGED
|
@@ -15,7 +15,13 @@ defineDashboardExtension({
|
|
|
15
15
|
},
|
|
16
16
|
requiresPermission: ['ReadCatalog', 'ReadProduct'],
|
|
17
17
|
shouldRender: context => !!context.entity?.id,
|
|
18
|
-
component: ({ context }) =>
|
|
18
|
+
component: ({ context }) => (
|
|
19
|
+
<ChannelAvailabilityBlock
|
|
20
|
+
productId={context.entity.id}
|
|
21
|
+
productName={context.entity.name}
|
|
22
|
+
globalEnabledHint={context.entity.enabled}
|
|
23
|
+
/>
|
|
24
|
+
),
|
|
19
25
|
},
|
|
20
26
|
],
|
|
21
27
|
});
|