medusa-product-helper 0.0.3 → 0.0.4
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.
|
@@ -1,6 +1,606 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
const adminSdk = require("@medusajs/admin-sdk");
|
|
3
|
+
const react = require("react");
|
|
4
|
+
const jsxRuntime = require("react/jsx-runtime");
|
|
5
|
+
const ui = require("@medusajs/ui");
|
|
6
|
+
const reactQuery = require("@tanstack/react-query");
|
|
7
|
+
const HideDefaultMetadataWidget = () => {
|
|
8
|
+
react.useEffect(() => {
|
|
9
|
+
const hideMetadataSection = () => {
|
|
10
|
+
const headings = document.querySelectorAll("h2");
|
|
11
|
+
headings.forEach((heading) => {
|
|
12
|
+
var _a;
|
|
13
|
+
if (((_a = heading.textContent) == null ? void 0 : _a.trim()) === "Metadata") {
|
|
14
|
+
let container = heading.parentElement;
|
|
15
|
+
while (container && container !== document.body) {
|
|
16
|
+
const hasContainerClass = container.classList.toString().includes("Container");
|
|
17
|
+
const isInSidebar = container.closest('[class*="Sidebar"]') || container.closest('[class*="sidebar"]');
|
|
18
|
+
if (hasContainerClass || isInSidebar) {
|
|
19
|
+
const editLink = container.querySelector('a[href*="metadata/edit"]');
|
|
20
|
+
const badge = container.querySelector('div[class*="Badge"]');
|
|
21
|
+
if (editLink && badge) {
|
|
22
|
+
container.style.display = "none";
|
|
23
|
+
container.setAttribute("data-metadata-hidden", "true");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
container = container.parentElement;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
const runHide = () => {
|
|
33
|
+
setTimeout(hideMetadataSection, 100);
|
|
34
|
+
};
|
|
35
|
+
runHide();
|
|
36
|
+
const observer = new MutationObserver(() => {
|
|
37
|
+
const alreadyHidden = document.querySelector('[data-metadata-hidden="true"]');
|
|
38
|
+
if (!alreadyHidden) {
|
|
39
|
+
runHide();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
observer.observe(document.body, {
|
|
43
|
+
childList: true,
|
|
44
|
+
subtree: true
|
|
45
|
+
});
|
|
46
|
+
return () => {
|
|
47
|
+
observer.disconnect();
|
|
48
|
+
const hidden = document.querySelector('[data-metadata-hidden="true"]');
|
|
49
|
+
if (hidden) {
|
|
50
|
+
hidden.style.display = "";
|
|
51
|
+
hidden.removeAttribute("data-metadata-hidden");
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}, []);
|
|
55
|
+
return null;
|
|
56
|
+
};
|
|
57
|
+
adminSdk.defineWidgetConfig({
|
|
58
|
+
zone: "product.details.side.before"
|
|
59
|
+
});
|
|
60
|
+
const METADATA_FIELD_TYPES = ["number", "text", "file", "bool"];
|
|
61
|
+
const VALID_FIELD_TYPES = new Set(METADATA_FIELD_TYPES);
|
|
62
|
+
function normalizeMetadataDescriptors(input) {
|
|
63
|
+
if (!Array.isArray(input)) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
67
|
+
const normalized = [];
|
|
68
|
+
for (const item of input) {
|
|
69
|
+
if (!item || typeof item !== "object") {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const key = getNormalizedKey(item.key);
|
|
73
|
+
if (!key || seenKeys.has(key)) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const type = getNormalizedType(item.type);
|
|
77
|
+
if (!type) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const label = getNormalizedLabel(item.label);
|
|
81
|
+
normalized.push({
|
|
82
|
+
key,
|
|
83
|
+
type,
|
|
84
|
+
...label ? { label } : {}
|
|
85
|
+
});
|
|
86
|
+
seenKeys.add(key);
|
|
87
|
+
}
|
|
88
|
+
return normalized;
|
|
89
|
+
}
|
|
90
|
+
function buildInitialFormState(descriptors, metadata) {
|
|
91
|
+
return descriptors.reduce(
|
|
92
|
+
(acc, descriptor) => {
|
|
93
|
+
const currentValue = metadata && typeof metadata === "object" ? metadata[descriptor.key] : void 0;
|
|
94
|
+
acc[descriptor.key] = normalizeFormValue(descriptor, currentValue);
|
|
95
|
+
return acc;
|
|
96
|
+
},
|
|
97
|
+
{}
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
function buildMetadataPayload({
|
|
101
|
+
descriptors,
|
|
102
|
+
values,
|
|
103
|
+
originalMetadata
|
|
104
|
+
}) {
|
|
105
|
+
const base = originalMetadata && typeof originalMetadata === "object" ? { ...originalMetadata } : {};
|
|
106
|
+
descriptors.forEach((descriptor) => {
|
|
107
|
+
const rawValue = values[descriptor.key];
|
|
108
|
+
const coerced = coerceMetadataValue(descriptor, rawValue);
|
|
109
|
+
if (typeof coerced === "undefined") {
|
|
110
|
+
delete base[descriptor.key];
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
base[descriptor.key] = coerced;
|
|
114
|
+
});
|
|
115
|
+
return base;
|
|
116
|
+
}
|
|
117
|
+
function hasMetadataChanges({
|
|
118
|
+
descriptors,
|
|
119
|
+
values,
|
|
120
|
+
originalMetadata
|
|
121
|
+
}) {
|
|
122
|
+
const next = buildMetadataPayload({ descriptors, values, originalMetadata });
|
|
123
|
+
const prev = originalMetadata && typeof originalMetadata === "object" ? originalMetadata : {};
|
|
124
|
+
return descriptors.some((descriptor) => {
|
|
125
|
+
const prevValue = prev[descriptor.key];
|
|
126
|
+
const nextValue = next[descriptor.key];
|
|
127
|
+
return !isDeepEqual(prevValue, nextValue);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function validateValueForDescriptor(descriptor, value) {
|
|
131
|
+
if (descriptor.type === "number") {
|
|
132
|
+
if (value === "" || value === null || typeof value === "undefined") {
|
|
133
|
+
return void 0;
|
|
134
|
+
}
|
|
135
|
+
const numericValue = typeof value === "number" ? value : Number(String(value).trim());
|
|
136
|
+
if (Number.isNaN(numericValue)) {
|
|
137
|
+
return "Enter a valid number";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (descriptor.type === "file") {
|
|
141
|
+
if (!value) {
|
|
142
|
+
return void 0;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
new URL(String(value).trim());
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return "Enter a valid URL";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return void 0;
|
|
151
|
+
}
|
|
152
|
+
function normalizeFormValue(descriptor, currentValue) {
|
|
153
|
+
if (descriptor.type === "bool") {
|
|
154
|
+
return Boolean(currentValue);
|
|
155
|
+
}
|
|
156
|
+
if ((descriptor.type === "number" || descriptor.type === "text") && typeof currentValue === "number") {
|
|
157
|
+
return currentValue.toString();
|
|
158
|
+
}
|
|
159
|
+
if (typeof currentValue === "string" || typeof currentValue === "number") {
|
|
160
|
+
return String(currentValue);
|
|
161
|
+
}
|
|
162
|
+
return "";
|
|
163
|
+
}
|
|
164
|
+
function coerceMetadataValue(descriptor, value) {
|
|
165
|
+
if (value === "" || value === null || typeof value === "undefined") {
|
|
166
|
+
return void 0;
|
|
167
|
+
}
|
|
168
|
+
if (descriptor.type === "bool") {
|
|
169
|
+
if (typeof value === "boolean") {
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
if (typeof value === "number") {
|
|
173
|
+
return value !== 0;
|
|
174
|
+
}
|
|
175
|
+
if (typeof value === "string") {
|
|
176
|
+
const normalized = value.trim().toLowerCase();
|
|
177
|
+
if (!normalized) {
|
|
178
|
+
return void 0;
|
|
179
|
+
}
|
|
180
|
+
if (["true", "1", "yes", "y", "on"].includes(normalized)) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
if (["false", "0", "no", "n", "off"].includes(normalized)) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return Boolean(value);
|
|
188
|
+
}
|
|
189
|
+
if (descriptor.type === "number") {
|
|
190
|
+
if (typeof value === "number") {
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
const parsed = Number(String(value).trim());
|
|
194
|
+
return Number.isNaN(parsed) ? void 0 : parsed;
|
|
195
|
+
}
|
|
196
|
+
return String(value).trim();
|
|
197
|
+
}
|
|
198
|
+
function getNormalizedKey(value) {
|
|
199
|
+
if (typeof value !== "string") {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
const trimmed = value.trim();
|
|
203
|
+
return trimmed.length ? trimmed : null;
|
|
204
|
+
}
|
|
205
|
+
function getNormalizedType(value) {
|
|
206
|
+
if (typeof value !== "string") {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
const type = value.trim().toLowerCase();
|
|
210
|
+
return VALID_FIELD_TYPES.has(type) ? type : null;
|
|
211
|
+
}
|
|
212
|
+
function getNormalizedLabel(value) {
|
|
213
|
+
if (typeof value !== "string") {
|
|
214
|
+
return void 0;
|
|
215
|
+
}
|
|
216
|
+
const trimmed = value.trim();
|
|
217
|
+
return trimmed.length ? trimmed : void 0;
|
|
218
|
+
}
|
|
219
|
+
function isDeepEqual(a, b) {
|
|
220
|
+
if (a === b) {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
if (typeof a === "object" && typeof b === "object" && a !== null && b !== null) {
|
|
224
|
+
const aKeys = Object.keys(a);
|
|
225
|
+
const bKeys = Object.keys(b);
|
|
226
|
+
if (aKeys.length !== bKeys.length) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
return aKeys.every(
|
|
230
|
+
(key) => isDeepEqual(
|
|
231
|
+
a[key],
|
|
232
|
+
b[key]
|
|
233
|
+
)
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
const CONFIG_ENDPOINT = "/admin/product-metadata-config";
|
|
239
|
+
const QUERY_KEY = ["medusa-product-helper", "metadata-config"];
|
|
240
|
+
const useProductMetadataConfig = () => {
|
|
241
|
+
return reactQuery.useQuery({
|
|
242
|
+
queryKey: QUERY_KEY,
|
|
243
|
+
queryFn: async () => {
|
|
244
|
+
const response = await fetch(CONFIG_ENDPOINT, {
|
|
245
|
+
credentials: "include"
|
|
246
|
+
});
|
|
247
|
+
if (!response.ok) {
|
|
248
|
+
throw new Error("Unable to load metadata configuration");
|
|
249
|
+
}
|
|
250
|
+
const payload = await response.json();
|
|
251
|
+
return normalizeMetadataDescriptors(payload.metadataDescriptors);
|
|
252
|
+
},
|
|
253
|
+
staleTime: 5 * 60 * 1e3
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
const CONFIG_DOCS_URL = "https://docs.medusajs.com/admin/extension-points/widgets#product-details";
|
|
257
|
+
const ProductMetadataTableWidget = ({ data }) => {
|
|
258
|
+
const { data: descriptors = [], isPending, isError } = useProductMetadataConfig();
|
|
259
|
+
const metadata = (data == null ? void 0 : data.metadata) ?? {};
|
|
260
|
+
const [baselineMetadata, setBaselineMetadata] = react.useState(metadata);
|
|
261
|
+
const queryClient = reactQuery.useQueryClient();
|
|
262
|
+
react.useEffect(() => {
|
|
263
|
+
setBaselineMetadata(metadata);
|
|
264
|
+
}, [metadata]);
|
|
265
|
+
const initialState = react.useMemo(
|
|
266
|
+
() => buildInitialFormState(descriptors, baselineMetadata),
|
|
267
|
+
[descriptors, baselineMetadata]
|
|
268
|
+
);
|
|
269
|
+
const [values, setValues] = react.useState(
|
|
270
|
+
initialState
|
|
271
|
+
);
|
|
272
|
+
const [isSaving, setIsSaving] = react.useState(false);
|
|
273
|
+
react.useEffect(() => {
|
|
274
|
+
setValues(initialState);
|
|
275
|
+
}, [initialState]);
|
|
276
|
+
const errors = react.useMemo(() => {
|
|
277
|
+
return descriptors.reduce((acc, descriptor) => {
|
|
278
|
+
const error = validateValueForDescriptor(descriptor, values[descriptor.key]);
|
|
279
|
+
if (error) {
|
|
280
|
+
acc[descriptor.key] = error;
|
|
281
|
+
}
|
|
282
|
+
return acc;
|
|
283
|
+
}, {});
|
|
284
|
+
}, [descriptors, values]);
|
|
285
|
+
const hasErrors = Object.keys(errors).length > 0;
|
|
286
|
+
const isDirty = react.useMemo(() => {
|
|
287
|
+
return hasMetadataChanges({
|
|
288
|
+
descriptors,
|
|
289
|
+
values,
|
|
290
|
+
originalMetadata: baselineMetadata
|
|
291
|
+
});
|
|
292
|
+
}, [descriptors, values, baselineMetadata]);
|
|
293
|
+
const handleStringChange = (key, nextValue) => {
|
|
294
|
+
setValues((prev) => ({
|
|
295
|
+
...prev,
|
|
296
|
+
[key]: nextValue
|
|
297
|
+
}));
|
|
298
|
+
};
|
|
299
|
+
const handleBooleanChange = (key, nextValue) => {
|
|
300
|
+
setValues((prev) => ({
|
|
301
|
+
...prev,
|
|
302
|
+
[key]: nextValue
|
|
303
|
+
}));
|
|
304
|
+
};
|
|
305
|
+
const handleReset = () => {
|
|
306
|
+
setValues(initialState);
|
|
307
|
+
};
|
|
308
|
+
const handleSubmit = async () => {
|
|
309
|
+
if (!(data == null ? void 0 : data.id) || !descriptors.length) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
setIsSaving(true);
|
|
313
|
+
try {
|
|
314
|
+
const metadataPayload = buildMetadataPayload({
|
|
315
|
+
descriptors,
|
|
316
|
+
values,
|
|
317
|
+
originalMetadata: baselineMetadata
|
|
318
|
+
});
|
|
319
|
+
const response = await fetch(`/admin/products/${data.id}`, {
|
|
320
|
+
method: "POST",
|
|
321
|
+
credentials: "include",
|
|
322
|
+
headers: {
|
|
323
|
+
"Content-Type": "application/json"
|
|
324
|
+
},
|
|
325
|
+
body: JSON.stringify({
|
|
326
|
+
metadata: metadataPayload
|
|
327
|
+
})
|
|
328
|
+
});
|
|
329
|
+
if (!response.ok) {
|
|
330
|
+
const payload = await response.json().catch(() => null);
|
|
331
|
+
throw new Error((payload == null ? void 0 : payload.message) ?? "Unable to save metadata");
|
|
332
|
+
}
|
|
333
|
+
const updated = await response.json();
|
|
334
|
+
const nextMetadata = updated.product.metadata;
|
|
335
|
+
setBaselineMetadata(nextMetadata);
|
|
336
|
+
setValues(buildInitialFormState(descriptors, nextMetadata));
|
|
337
|
+
ui.toast.success("Metadata saved");
|
|
338
|
+
await queryClient.invalidateQueries({
|
|
339
|
+
queryKey: ["products"]
|
|
340
|
+
});
|
|
341
|
+
} catch (error) {
|
|
342
|
+
ui.toast.error(error instanceof Error ? error.message : "Save failed");
|
|
343
|
+
} finally {
|
|
344
|
+
setIsSaving(false);
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(ui.Container, { className: "flex flex-col gap-y-4", children: [
|
|
348
|
+
/* @__PURE__ */ jsxRuntime.jsxs("header", { className: "flex flex-col gap-y-1", children: [
|
|
349
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-x-3", children: [
|
|
350
|
+
/* @__PURE__ */ jsxRuntime.jsx(ui.Heading, { level: "h2", children: "Metadata" }),
|
|
351
|
+
/* @__PURE__ */ jsxRuntime.jsx(ui.Badge, { size: "2xsmall", rounded: "full", children: descriptors.length })
|
|
352
|
+
] }),
|
|
353
|
+
/* @__PURE__ */ jsxRuntime.jsx(ui.Text, { className: "text-ui-fg-subtle", children: "Structured metadata mapped to the keys you configured in the plugin options." })
|
|
354
|
+
] }),
|
|
355
|
+
isPending ? /* @__PURE__ */ jsxRuntime.jsx(ui.Skeleton, { className: "h-[160px] w-full" }) : isError ? /* @__PURE__ */ jsxRuntime.jsxs(ui.InlineTip, { variant: "error", label: "Configuration unavailable", children: [
|
|
356
|
+
"Unable to load metadata configuration for this plugin. Confirm that the plugin is registered with options in ",
|
|
357
|
+
/* @__PURE__ */ jsxRuntime.jsx("code", { children: "medusa-config.ts" }),
|
|
358
|
+
"."
|
|
359
|
+
] }) : !descriptors.length ? /* @__PURE__ */ jsxRuntime.jsxs(ui.InlineTip, { variant: "info", label: "No configured metadata keys", children: [
|
|
360
|
+
"Provide a ",
|
|
361
|
+
/* @__PURE__ */ jsxRuntime.jsx("code", { children: "metadataDescriptors" }),
|
|
362
|
+
" array in the plugin options to control which keys show up here.",
|
|
363
|
+
" ",
|
|
364
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
365
|
+
"a",
|
|
366
|
+
{
|
|
367
|
+
className: "text-ui-fg-interactive underline",
|
|
368
|
+
href: CONFIG_DOCS_URL,
|
|
369
|
+
target: "_blank",
|
|
370
|
+
rel: "noreferrer",
|
|
371
|
+
children: "Learn how to configure it."
|
|
372
|
+
}
|
|
373
|
+
)
|
|
374
|
+
] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
|
|
375
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "overflow-hidden rounded-lg border border-ui-border-base", children: /* @__PURE__ */ jsxRuntime.jsxs("table", { className: "min-w-full divide-y divide-ui-border-base", children: [
|
|
376
|
+
/* @__PURE__ */ jsxRuntime.jsx("thead", { className: "bg-ui-bg-subtle", children: /* @__PURE__ */ jsxRuntime.jsxs("tr", { children: [
|
|
377
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
378
|
+
"th",
|
|
379
|
+
{
|
|
380
|
+
scope: "col",
|
|
381
|
+
className: "txt-compact-xsmall-plus text-left uppercase tracking-wide text-ui-fg-muted px-4 py-3",
|
|
382
|
+
children: "Label"
|
|
383
|
+
}
|
|
384
|
+
),
|
|
385
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
386
|
+
"th",
|
|
387
|
+
{
|
|
388
|
+
scope: "col",
|
|
389
|
+
className: "txt-compact-xsmall-plus text-left uppercase tracking-wide text-ui-fg-muted px-4 py-3",
|
|
390
|
+
children: "Value"
|
|
391
|
+
}
|
|
392
|
+
)
|
|
393
|
+
] }) }),
|
|
394
|
+
/* @__PURE__ */ jsxRuntime.jsx("tbody", { className: "divide-y divide-ui-border-subtle bg-ui-bg-base", children: descriptors.map((descriptor) => {
|
|
395
|
+
const value = values[descriptor.key];
|
|
396
|
+
const error = errors[descriptor.key];
|
|
397
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("tr", { children: [
|
|
398
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
399
|
+
"th",
|
|
400
|
+
{
|
|
401
|
+
scope: "row",
|
|
402
|
+
className: "txt-compact-medium text-ui-fg-base align-top px-4 py-4",
|
|
403
|
+
children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-1", children: [
|
|
404
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { children: descriptor.label ?? descriptor.key }),
|
|
405
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "txt-compact-xsmall-plus text-ui-fg-muted uppercase tracking-wide", children: descriptor.type })
|
|
406
|
+
] })
|
|
407
|
+
}
|
|
408
|
+
),
|
|
409
|
+
/* @__PURE__ */ jsxRuntime.jsx("td", { className: "align-top px-4 py-4", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-2", children: [
|
|
410
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
411
|
+
ValueField,
|
|
412
|
+
{
|
|
413
|
+
descriptor,
|
|
414
|
+
value,
|
|
415
|
+
onStringChange: handleStringChange,
|
|
416
|
+
onBooleanChange: handleBooleanChange
|
|
417
|
+
}
|
|
418
|
+
),
|
|
419
|
+
error && /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { className: "txt-compact-small text-ui-fg-error", children: error })
|
|
420
|
+
] }) })
|
|
421
|
+
] }, descriptor.key);
|
|
422
|
+
}) })
|
|
423
|
+
] }) }),
|
|
424
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-3 border-t border-ui-border-subtle pt-3 md:flex-row md:items-center md:justify-between", children: [
|
|
425
|
+
/* @__PURE__ */ jsxRuntime.jsx(ui.Text, { className: "text-ui-fg-muted", children: "Changes are stored on the product metadata object. Clearing a field removes the corresponding key on save." }),
|
|
426
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-x-2", children: [
|
|
427
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
428
|
+
ui.Button,
|
|
429
|
+
{
|
|
430
|
+
variant: "secondary",
|
|
431
|
+
size: "small",
|
|
432
|
+
disabled: !isDirty || isSaving,
|
|
433
|
+
onClick: handleReset,
|
|
434
|
+
children: "Reset"
|
|
435
|
+
}
|
|
436
|
+
),
|
|
437
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
438
|
+
ui.Button,
|
|
439
|
+
{
|
|
440
|
+
size: "small",
|
|
441
|
+
onClick: handleSubmit,
|
|
442
|
+
disabled: !isDirty || hasErrors || isSaving,
|
|
443
|
+
isLoading: isSaving,
|
|
444
|
+
children: "Save metadata"
|
|
445
|
+
}
|
|
446
|
+
)
|
|
447
|
+
] })
|
|
448
|
+
] })
|
|
449
|
+
] })
|
|
450
|
+
] });
|
|
451
|
+
};
|
|
452
|
+
const ValueField = ({
|
|
453
|
+
descriptor,
|
|
454
|
+
value,
|
|
455
|
+
onStringChange,
|
|
456
|
+
onBooleanChange
|
|
457
|
+
}) => {
|
|
458
|
+
const fileInputRef = react.useRef(null);
|
|
459
|
+
const [isUploading, setIsUploading] = react.useState(false);
|
|
460
|
+
const handleFileUpload = async (event) => {
|
|
461
|
+
var _a;
|
|
462
|
+
const file = (_a = event.target.files) == null ? void 0 : _a[0];
|
|
463
|
+
if (!file) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
setIsUploading(true);
|
|
467
|
+
try {
|
|
468
|
+
const formData = new FormData();
|
|
469
|
+
formData.append("files", file);
|
|
470
|
+
const response = await fetch("/admin/uploads", {
|
|
471
|
+
method: "POST",
|
|
472
|
+
credentials: "include",
|
|
473
|
+
body: formData
|
|
474
|
+
});
|
|
475
|
+
if (!response.ok) {
|
|
476
|
+
const payload = await response.json().catch(() => null);
|
|
477
|
+
throw new Error((payload == null ? void 0 : payload.message) ?? "File upload failed");
|
|
478
|
+
}
|
|
479
|
+
const result = await response.json();
|
|
480
|
+
if (result.files && result.files.length > 0) {
|
|
481
|
+
const uploadedFile = result.files[0];
|
|
482
|
+
const fileUrl = uploadedFile.url || uploadedFile.key;
|
|
483
|
+
if (fileUrl) {
|
|
484
|
+
onStringChange(descriptor.key, fileUrl);
|
|
485
|
+
ui.toast.success("File uploaded successfully");
|
|
486
|
+
} else {
|
|
487
|
+
throw new Error("File upload succeeded but no URL returned");
|
|
488
|
+
}
|
|
489
|
+
} else {
|
|
490
|
+
throw new Error("File upload failed - no files returned");
|
|
491
|
+
}
|
|
492
|
+
} catch (error) {
|
|
493
|
+
ui.toast.error(
|
|
494
|
+
error instanceof Error ? error.message : "Failed to upload file"
|
|
495
|
+
);
|
|
496
|
+
} finally {
|
|
497
|
+
setIsUploading(false);
|
|
498
|
+
if (fileInputRef.current) {
|
|
499
|
+
fileInputRef.current.value = "";
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
if (descriptor.type === "bool") {
|
|
504
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-x-2", children: [
|
|
505
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
506
|
+
ui.Switch,
|
|
507
|
+
{
|
|
508
|
+
checked: Boolean(value),
|
|
509
|
+
onCheckedChange: (checked) => onBooleanChange(descriptor.key, Boolean(checked)),
|
|
510
|
+
"aria-label": `Toggle ${descriptor.label ?? descriptor.key}`
|
|
511
|
+
}
|
|
512
|
+
),
|
|
513
|
+
/* @__PURE__ */ jsxRuntime.jsx(ui.Text, { className: "txt-compact-small text-ui-fg-muted", children: Boolean(value) ? "True" : "False" })
|
|
514
|
+
] });
|
|
515
|
+
}
|
|
516
|
+
if (descriptor.type === "text") {
|
|
517
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
518
|
+
ui.Textarea,
|
|
519
|
+
{
|
|
520
|
+
value: value ?? "",
|
|
521
|
+
placeholder: "Enter text",
|
|
522
|
+
rows: 3,
|
|
523
|
+
onChange: (event) => onStringChange(descriptor.key, event.target.value)
|
|
524
|
+
}
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
if (descriptor.type === "number") {
|
|
528
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
529
|
+
ui.Input,
|
|
530
|
+
{
|
|
531
|
+
type: "text",
|
|
532
|
+
inputMode: "decimal",
|
|
533
|
+
placeholder: "0.00",
|
|
534
|
+
value: value ?? "",
|
|
535
|
+
onChange: (event) => onStringChange(descriptor.key, event.target.value)
|
|
536
|
+
}
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-2", children: [
|
|
540
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-x-2", children: [
|
|
541
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
542
|
+
ui.Input,
|
|
543
|
+
{
|
|
544
|
+
type: "url",
|
|
545
|
+
placeholder: "https://example.com/file",
|
|
546
|
+
value: value ?? "",
|
|
547
|
+
onChange: (event) => onStringChange(descriptor.key, event.target.value),
|
|
548
|
+
className: "flex-1"
|
|
549
|
+
}
|
|
550
|
+
),
|
|
551
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
552
|
+
"input",
|
|
553
|
+
{
|
|
554
|
+
ref: fileInputRef,
|
|
555
|
+
type: "file",
|
|
556
|
+
className: "hidden",
|
|
557
|
+
onChange: handleFileUpload,
|
|
558
|
+
disabled: isUploading,
|
|
559
|
+
"aria-label": `Upload file for ${descriptor.label ?? descriptor.key}`
|
|
560
|
+
}
|
|
561
|
+
),
|
|
562
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
563
|
+
ui.Button,
|
|
564
|
+
{
|
|
565
|
+
type: "button",
|
|
566
|
+
variant: "secondary",
|
|
567
|
+
size: "small",
|
|
568
|
+
onClick: () => {
|
|
569
|
+
var _a;
|
|
570
|
+
return (_a = fileInputRef.current) == null ? void 0 : _a.click();
|
|
571
|
+
},
|
|
572
|
+
disabled: isUploading,
|
|
573
|
+
isLoading: isUploading,
|
|
574
|
+
children: isUploading ? "Uploading..." : "Upload"
|
|
575
|
+
}
|
|
576
|
+
)
|
|
577
|
+
] }),
|
|
578
|
+
typeof value === "string" && value && /* @__PURE__ */ jsxRuntime.jsx(
|
|
579
|
+
"a",
|
|
580
|
+
{
|
|
581
|
+
className: "txt-compact-small-plus text-ui-fg-interactive underline",
|
|
582
|
+
href: value,
|
|
583
|
+
target: "_blank",
|
|
584
|
+
rel: "noreferrer",
|
|
585
|
+
children: "View file"
|
|
586
|
+
}
|
|
587
|
+
)
|
|
588
|
+
] });
|
|
589
|
+
};
|
|
590
|
+
adminSdk.defineWidgetConfig({
|
|
591
|
+
zone: "product.details.after"
|
|
592
|
+
});
|
|
2
593
|
const i18nTranslations0 = {};
|
|
3
|
-
const widgetModule = { widgets: [
|
|
594
|
+
const widgetModule = { widgets: [
|
|
595
|
+
{
|
|
596
|
+
Component: HideDefaultMetadataWidget,
|
|
597
|
+
zone: ["product.details.side.before"]
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
Component: ProductMetadataTableWidget,
|
|
601
|
+
zone: ["product.details.after"]
|
|
602
|
+
}
|
|
603
|
+
] };
|
|
4
604
|
const routeModule = {
|
|
5
605
|
routes: []
|
|
6
606
|
};
|
|
@@ -1,5 +1,605 @@
|
|
|
1
|
+
import { defineWidgetConfig } from "@medusajs/admin-sdk";
|
|
2
|
+
import { useEffect, useState, useMemo, useRef } from "react";
|
|
3
|
+
import { jsxs, jsx, Fragment } from "react/jsx-runtime";
|
|
4
|
+
import { Container, Heading, Badge, Text, Skeleton, InlineTip, Button, Switch, Textarea, Input, toast } from "@medusajs/ui";
|
|
5
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
6
|
+
const HideDefaultMetadataWidget = () => {
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const hideMetadataSection = () => {
|
|
9
|
+
const headings = document.querySelectorAll("h2");
|
|
10
|
+
headings.forEach((heading) => {
|
|
11
|
+
var _a;
|
|
12
|
+
if (((_a = heading.textContent) == null ? void 0 : _a.trim()) === "Metadata") {
|
|
13
|
+
let container = heading.parentElement;
|
|
14
|
+
while (container && container !== document.body) {
|
|
15
|
+
const hasContainerClass = container.classList.toString().includes("Container");
|
|
16
|
+
const isInSidebar = container.closest('[class*="Sidebar"]') || container.closest('[class*="sidebar"]');
|
|
17
|
+
if (hasContainerClass || isInSidebar) {
|
|
18
|
+
const editLink = container.querySelector('a[href*="metadata/edit"]');
|
|
19
|
+
const badge = container.querySelector('div[class*="Badge"]');
|
|
20
|
+
if (editLink && badge) {
|
|
21
|
+
container.style.display = "none";
|
|
22
|
+
container.setAttribute("data-metadata-hidden", "true");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
container = container.parentElement;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
const runHide = () => {
|
|
32
|
+
setTimeout(hideMetadataSection, 100);
|
|
33
|
+
};
|
|
34
|
+
runHide();
|
|
35
|
+
const observer = new MutationObserver(() => {
|
|
36
|
+
const alreadyHidden = document.querySelector('[data-metadata-hidden="true"]');
|
|
37
|
+
if (!alreadyHidden) {
|
|
38
|
+
runHide();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
observer.observe(document.body, {
|
|
42
|
+
childList: true,
|
|
43
|
+
subtree: true
|
|
44
|
+
});
|
|
45
|
+
return () => {
|
|
46
|
+
observer.disconnect();
|
|
47
|
+
const hidden = document.querySelector('[data-metadata-hidden="true"]');
|
|
48
|
+
if (hidden) {
|
|
49
|
+
hidden.style.display = "";
|
|
50
|
+
hidden.removeAttribute("data-metadata-hidden");
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}, []);
|
|
54
|
+
return null;
|
|
55
|
+
};
|
|
56
|
+
defineWidgetConfig({
|
|
57
|
+
zone: "product.details.side.before"
|
|
58
|
+
});
|
|
59
|
+
const METADATA_FIELD_TYPES = ["number", "text", "file", "bool"];
|
|
60
|
+
const VALID_FIELD_TYPES = new Set(METADATA_FIELD_TYPES);
|
|
61
|
+
function normalizeMetadataDescriptors(input) {
|
|
62
|
+
if (!Array.isArray(input)) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
66
|
+
const normalized = [];
|
|
67
|
+
for (const item of input) {
|
|
68
|
+
if (!item || typeof item !== "object") {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const key = getNormalizedKey(item.key);
|
|
72
|
+
if (!key || seenKeys.has(key)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const type = getNormalizedType(item.type);
|
|
76
|
+
if (!type) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const label = getNormalizedLabel(item.label);
|
|
80
|
+
normalized.push({
|
|
81
|
+
key,
|
|
82
|
+
type,
|
|
83
|
+
...label ? { label } : {}
|
|
84
|
+
});
|
|
85
|
+
seenKeys.add(key);
|
|
86
|
+
}
|
|
87
|
+
return normalized;
|
|
88
|
+
}
|
|
89
|
+
function buildInitialFormState(descriptors, metadata) {
|
|
90
|
+
return descriptors.reduce(
|
|
91
|
+
(acc, descriptor) => {
|
|
92
|
+
const currentValue = metadata && typeof metadata === "object" ? metadata[descriptor.key] : void 0;
|
|
93
|
+
acc[descriptor.key] = normalizeFormValue(descriptor, currentValue);
|
|
94
|
+
return acc;
|
|
95
|
+
},
|
|
96
|
+
{}
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
function buildMetadataPayload({
|
|
100
|
+
descriptors,
|
|
101
|
+
values,
|
|
102
|
+
originalMetadata
|
|
103
|
+
}) {
|
|
104
|
+
const base = originalMetadata && typeof originalMetadata === "object" ? { ...originalMetadata } : {};
|
|
105
|
+
descriptors.forEach((descriptor) => {
|
|
106
|
+
const rawValue = values[descriptor.key];
|
|
107
|
+
const coerced = coerceMetadataValue(descriptor, rawValue);
|
|
108
|
+
if (typeof coerced === "undefined") {
|
|
109
|
+
delete base[descriptor.key];
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
base[descriptor.key] = coerced;
|
|
113
|
+
});
|
|
114
|
+
return base;
|
|
115
|
+
}
|
|
116
|
+
function hasMetadataChanges({
|
|
117
|
+
descriptors,
|
|
118
|
+
values,
|
|
119
|
+
originalMetadata
|
|
120
|
+
}) {
|
|
121
|
+
const next = buildMetadataPayload({ descriptors, values, originalMetadata });
|
|
122
|
+
const prev = originalMetadata && typeof originalMetadata === "object" ? originalMetadata : {};
|
|
123
|
+
return descriptors.some((descriptor) => {
|
|
124
|
+
const prevValue = prev[descriptor.key];
|
|
125
|
+
const nextValue = next[descriptor.key];
|
|
126
|
+
return !isDeepEqual(prevValue, nextValue);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function validateValueForDescriptor(descriptor, value) {
|
|
130
|
+
if (descriptor.type === "number") {
|
|
131
|
+
if (value === "" || value === null || typeof value === "undefined") {
|
|
132
|
+
return void 0;
|
|
133
|
+
}
|
|
134
|
+
const numericValue = typeof value === "number" ? value : Number(String(value).trim());
|
|
135
|
+
if (Number.isNaN(numericValue)) {
|
|
136
|
+
return "Enter a valid number";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (descriptor.type === "file") {
|
|
140
|
+
if (!value) {
|
|
141
|
+
return void 0;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
new URL(String(value).trim());
|
|
145
|
+
} catch (err) {
|
|
146
|
+
return "Enter a valid URL";
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return void 0;
|
|
150
|
+
}
|
|
151
|
+
function normalizeFormValue(descriptor, currentValue) {
|
|
152
|
+
if (descriptor.type === "bool") {
|
|
153
|
+
return Boolean(currentValue);
|
|
154
|
+
}
|
|
155
|
+
if ((descriptor.type === "number" || descriptor.type === "text") && typeof currentValue === "number") {
|
|
156
|
+
return currentValue.toString();
|
|
157
|
+
}
|
|
158
|
+
if (typeof currentValue === "string" || typeof currentValue === "number") {
|
|
159
|
+
return String(currentValue);
|
|
160
|
+
}
|
|
161
|
+
return "";
|
|
162
|
+
}
|
|
163
|
+
function coerceMetadataValue(descriptor, value) {
|
|
164
|
+
if (value === "" || value === null || typeof value === "undefined") {
|
|
165
|
+
return void 0;
|
|
166
|
+
}
|
|
167
|
+
if (descriptor.type === "bool") {
|
|
168
|
+
if (typeof value === "boolean") {
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
if (typeof value === "number") {
|
|
172
|
+
return value !== 0;
|
|
173
|
+
}
|
|
174
|
+
if (typeof value === "string") {
|
|
175
|
+
const normalized = value.trim().toLowerCase();
|
|
176
|
+
if (!normalized) {
|
|
177
|
+
return void 0;
|
|
178
|
+
}
|
|
179
|
+
if (["true", "1", "yes", "y", "on"].includes(normalized)) {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
if (["false", "0", "no", "n", "off"].includes(normalized)) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return Boolean(value);
|
|
187
|
+
}
|
|
188
|
+
if (descriptor.type === "number") {
|
|
189
|
+
if (typeof value === "number") {
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
192
|
+
const parsed = Number(String(value).trim());
|
|
193
|
+
return Number.isNaN(parsed) ? void 0 : parsed;
|
|
194
|
+
}
|
|
195
|
+
return String(value).trim();
|
|
196
|
+
}
|
|
197
|
+
function getNormalizedKey(value) {
|
|
198
|
+
if (typeof value !== "string") {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
const trimmed = value.trim();
|
|
202
|
+
return trimmed.length ? trimmed : null;
|
|
203
|
+
}
|
|
204
|
+
function getNormalizedType(value) {
|
|
205
|
+
if (typeof value !== "string") {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const type = value.trim().toLowerCase();
|
|
209
|
+
return VALID_FIELD_TYPES.has(type) ? type : null;
|
|
210
|
+
}
|
|
211
|
+
function getNormalizedLabel(value) {
|
|
212
|
+
if (typeof value !== "string") {
|
|
213
|
+
return void 0;
|
|
214
|
+
}
|
|
215
|
+
const trimmed = value.trim();
|
|
216
|
+
return trimmed.length ? trimmed : void 0;
|
|
217
|
+
}
|
|
218
|
+
function isDeepEqual(a, b) {
|
|
219
|
+
if (a === b) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
if (typeof a === "object" && typeof b === "object" && a !== null && b !== null) {
|
|
223
|
+
const aKeys = Object.keys(a);
|
|
224
|
+
const bKeys = Object.keys(b);
|
|
225
|
+
if (aKeys.length !== bKeys.length) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
return aKeys.every(
|
|
229
|
+
(key) => isDeepEqual(
|
|
230
|
+
a[key],
|
|
231
|
+
b[key]
|
|
232
|
+
)
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
const CONFIG_ENDPOINT = "/admin/product-metadata-config";
|
|
238
|
+
const QUERY_KEY = ["medusa-product-helper", "metadata-config"];
|
|
239
|
+
const useProductMetadataConfig = () => {
|
|
240
|
+
return useQuery({
|
|
241
|
+
queryKey: QUERY_KEY,
|
|
242
|
+
queryFn: async () => {
|
|
243
|
+
const response = await fetch(CONFIG_ENDPOINT, {
|
|
244
|
+
credentials: "include"
|
|
245
|
+
});
|
|
246
|
+
if (!response.ok) {
|
|
247
|
+
throw new Error("Unable to load metadata configuration");
|
|
248
|
+
}
|
|
249
|
+
const payload = await response.json();
|
|
250
|
+
return normalizeMetadataDescriptors(payload.metadataDescriptors);
|
|
251
|
+
},
|
|
252
|
+
staleTime: 5 * 60 * 1e3
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
const CONFIG_DOCS_URL = "https://docs.medusajs.com/admin/extension-points/widgets#product-details";
|
|
256
|
+
const ProductMetadataTableWidget = ({ data }) => {
|
|
257
|
+
const { data: descriptors = [], isPending, isError } = useProductMetadataConfig();
|
|
258
|
+
const metadata = (data == null ? void 0 : data.metadata) ?? {};
|
|
259
|
+
const [baselineMetadata, setBaselineMetadata] = useState(metadata);
|
|
260
|
+
const queryClient = useQueryClient();
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
setBaselineMetadata(metadata);
|
|
263
|
+
}, [metadata]);
|
|
264
|
+
const initialState = useMemo(
|
|
265
|
+
() => buildInitialFormState(descriptors, baselineMetadata),
|
|
266
|
+
[descriptors, baselineMetadata]
|
|
267
|
+
);
|
|
268
|
+
const [values, setValues] = useState(
|
|
269
|
+
initialState
|
|
270
|
+
);
|
|
271
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
setValues(initialState);
|
|
274
|
+
}, [initialState]);
|
|
275
|
+
const errors = useMemo(() => {
|
|
276
|
+
return descriptors.reduce((acc, descriptor) => {
|
|
277
|
+
const error = validateValueForDescriptor(descriptor, values[descriptor.key]);
|
|
278
|
+
if (error) {
|
|
279
|
+
acc[descriptor.key] = error;
|
|
280
|
+
}
|
|
281
|
+
return acc;
|
|
282
|
+
}, {});
|
|
283
|
+
}, [descriptors, values]);
|
|
284
|
+
const hasErrors = Object.keys(errors).length > 0;
|
|
285
|
+
const isDirty = useMemo(() => {
|
|
286
|
+
return hasMetadataChanges({
|
|
287
|
+
descriptors,
|
|
288
|
+
values,
|
|
289
|
+
originalMetadata: baselineMetadata
|
|
290
|
+
});
|
|
291
|
+
}, [descriptors, values, baselineMetadata]);
|
|
292
|
+
const handleStringChange = (key, nextValue) => {
|
|
293
|
+
setValues((prev) => ({
|
|
294
|
+
...prev,
|
|
295
|
+
[key]: nextValue
|
|
296
|
+
}));
|
|
297
|
+
};
|
|
298
|
+
const handleBooleanChange = (key, nextValue) => {
|
|
299
|
+
setValues((prev) => ({
|
|
300
|
+
...prev,
|
|
301
|
+
[key]: nextValue
|
|
302
|
+
}));
|
|
303
|
+
};
|
|
304
|
+
const handleReset = () => {
|
|
305
|
+
setValues(initialState);
|
|
306
|
+
};
|
|
307
|
+
const handleSubmit = async () => {
|
|
308
|
+
if (!(data == null ? void 0 : data.id) || !descriptors.length) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
setIsSaving(true);
|
|
312
|
+
try {
|
|
313
|
+
const metadataPayload = buildMetadataPayload({
|
|
314
|
+
descriptors,
|
|
315
|
+
values,
|
|
316
|
+
originalMetadata: baselineMetadata
|
|
317
|
+
});
|
|
318
|
+
const response = await fetch(`/admin/products/${data.id}`, {
|
|
319
|
+
method: "POST",
|
|
320
|
+
credentials: "include",
|
|
321
|
+
headers: {
|
|
322
|
+
"Content-Type": "application/json"
|
|
323
|
+
},
|
|
324
|
+
body: JSON.stringify({
|
|
325
|
+
metadata: metadataPayload
|
|
326
|
+
})
|
|
327
|
+
});
|
|
328
|
+
if (!response.ok) {
|
|
329
|
+
const payload = await response.json().catch(() => null);
|
|
330
|
+
throw new Error((payload == null ? void 0 : payload.message) ?? "Unable to save metadata");
|
|
331
|
+
}
|
|
332
|
+
const updated = await response.json();
|
|
333
|
+
const nextMetadata = updated.product.metadata;
|
|
334
|
+
setBaselineMetadata(nextMetadata);
|
|
335
|
+
setValues(buildInitialFormState(descriptors, nextMetadata));
|
|
336
|
+
toast.success("Metadata saved");
|
|
337
|
+
await queryClient.invalidateQueries({
|
|
338
|
+
queryKey: ["products"]
|
|
339
|
+
});
|
|
340
|
+
} catch (error) {
|
|
341
|
+
toast.error(error instanceof Error ? error.message : "Save failed");
|
|
342
|
+
} finally {
|
|
343
|
+
setIsSaving(false);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
return /* @__PURE__ */ jsxs(Container, { className: "flex flex-col gap-y-4", children: [
|
|
347
|
+
/* @__PURE__ */ jsxs("header", { className: "flex flex-col gap-y-1", children: [
|
|
348
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-x-3", children: [
|
|
349
|
+
/* @__PURE__ */ jsx(Heading, { level: "h2", children: "Metadata" }),
|
|
350
|
+
/* @__PURE__ */ jsx(Badge, { size: "2xsmall", rounded: "full", children: descriptors.length })
|
|
351
|
+
] }),
|
|
352
|
+
/* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", children: "Structured metadata mapped to the keys you configured in the plugin options." })
|
|
353
|
+
] }),
|
|
354
|
+
isPending ? /* @__PURE__ */ jsx(Skeleton, { className: "h-[160px] w-full" }) : isError ? /* @__PURE__ */ jsxs(InlineTip, { variant: "error", label: "Configuration unavailable", children: [
|
|
355
|
+
"Unable to load metadata configuration for this plugin. Confirm that the plugin is registered with options in ",
|
|
356
|
+
/* @__PURE__ */ jsx("code", { children: "medusa-config.ts" }),
|
|
357
|
+
"."
|
|
358
|
+
] }) : !descriptors.length ? /* @__PURE__ */ jsxs(InlineTip, { variant: "info", label: "No configured metadata keys", children: [
|
|
359
|
+
"Provide a ",
|
|
360
|
+
/* @__PURE__ */ jsx("code", { children: "metadataDescriptors" }),
|
|
361
|
+
" array in the plugin options to control which keys show up here.",
|
|
362
|
+
" ",
|
|
363
|
+
/* @__PURE__ */ jsx(
|
|
364
|
+
"a",
|
|
365
|
+
{
|
|
366
|
+
className: "text-ui-fg-interactive underline",
|
|
367
|
+
href: CONFIG_DOCS_URL,
|
|
368
|
+
target: "_blank",
|
|
369
|
+
rel: "noreferrer",
|
|
370
|
+
children: "Learn how to configure it."
|
|
371
|
+
}
|
|
372
|
+
)
|
|
373
|
+
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
374
|
+
/* @__PURE__ */ jsx("div", { className: "overflow-hidden rounded-lg border border-ui-border-base", children: /* @__PURE__ */ jsxs("table", { className: "min-w-full divide-y divide-ui-border-base", children: [
|
|
375
|
+
/* @__PURE__ */ jsx("thead", { className: "bg-ui-bg-subtle", children: /* @__PURE__ */ jsxs("tr", { children: [
|
|
376
|
+
/* @__PURE__ */ jsx(
|
|
377
|
+
"th",
|
|
378
|
+
{
|
|
379
|
+
scope: "col",
|
|
380
|
+
className: "txt-compact-xsmall-plus text-left uppercase tracking-wide text-ui-fg-muted px-4 py-3",
|
|
381
|
+
children: "Label"
|
|
382
|
+
}
|
|
383
|
+
),
|
|
384
|
+
/* @__PURE__ */ jsx(
|
|
385
|
+
"th",
|
|
386
|
+
{
|
|
387
|
+
scope: "col",
|
|
388
|
+
className: "txt-compact-xsmall-plus text-left uppercase tracking-wide text-ui-fg-muted px-4 py-3",
|
|
389
|
+
children: "Value"
|
|
390
|
+
}
|
|
391
|
+
)
|
|
392
|
+
] }) }),
|
|
393
|
+
/* @__PURE__ */ jsx("tbody", { className: "divide-y divide-ui-border-subtle bg-ui-bg-base", children: descriptors.map((descriptor) => {
|
|
394
|
+
const value = values[descriptor.key];
|
|
395
|
+
const error = errors[descriptor.key];
|
|
396
|
+
return /* @__PURE__ */ jsxs("tr", { children: [
|
|
397
|
+
/* @__PURE__ */ jsx(
|
|
398
|
+
"th",
|
|
399
|
+
{
|
|
400
|
+
scope: "row",
|
|
401
|
+
className: "txt-compact-medium text-ui-fg-base align-top px-4 py-4",
|
|
402
|
+
children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-y-1", children: [
|
|
403
|
+
/* @__PURE__ */ jsx("span", { children: descriptor.label ?? descriptor.key }),
|
|
404
|
+
/* @__PURE__ */ jsx("span", { className: "txt-compact-xsmall-plus text-ui-fg-muted uppercase tracking-wide", children: descriptor.type })
|
|
405
|
+
] })
|
|
406
|
+
}
|
|
407
|
+
),
|
|
408
|
+
/* @__PURE__ */ jsx("td", { className: "align-top px-4 py-4", children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-y-2", children: [
|
|
409
|
+
/* @__PURE__ */ jsx(
|
|
410
|
+
ValueField,
|
|
411
|
+
{
|
|
412
|
+
descriptor,
|
|
413
|
+
value,
|
|
414
|
+
onStringChange: handleStringChange,
|
|
415
|
+
onBooleanChange: handleBooleanChange
|
|
416
|
+
}
|
|
417
|
+
),
|
|
418
|
+
error && /* @__PURE__ */ jsx(Text, { className: "txt-compact-small text-ui-fg-error", children: error })
|
|
419
|
+
] }) })
|
|
420
|
+
] }, descriptor.key);
|
|
421
|
+
}) })
|
|
422
|
+
] }) }),
|
|
423
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-y-3 border-t border-ui-border-subtle pt-3 md:flex-row md:items-center md:justify-between", children: [
|
|
424
|
+
/* @__PURE__ */ jsx(Text, { className: "text-ui-fg-muted", children: "Changes are stored on the product metadata object. Clearing a field removes the corresponding key on save." }),
|
|
425
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-x-2", children: [
|
|
426
|
+
/* @__PURE__ */ jsx(
|
|
427
|
+
Button,
|
|
428
|
+
{
|
|
429
|
+
variant: "secondary",
|
|
430
|
+
size: "small",
|
|
431
|
+
disabled: !isDirty || isSaving,
|
|
432
|
+
onClick: handleReset,
|
|
433
|
+
children: "Reset"
|
|
434
|
+
}
|
|
435
|
+
),
|
|
436
|
+
/* @__PURE__ */ jsx(
|
|
437
|
+
Button,
|
|
438
|
+
{
|
|
439
|
+
size: "small",
|
|
440
|
+
onClick: handleSubmit,
|
|
441
|
+
disabled: !isDirty || hasErrors || isSaving,
|
|
442
|
+
isLoading: isSaving,
|
|
443
|
+
children: "Save metadata"
|
|
444
|
+
}
|
|
445
|
+
)
|
|
446
|
+
] })
|
|
447
|
+
] })
|
|
448
|
+
] })
|
|
449
|
+
] });
|
|
450
|
+
};
|
|
451
|
+
const ValueField = ({
|
|
452
|
+
descriptor,
|
|
453
|
+
value,
|
|
454
|
+
onStringChange,
|
|
455
|
+
onBooleanChange
|
|
456
|
+
}) => {
|
|
457
|
+
const fileInputRef = useRef(null);
|
|
458
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
459
|
+
const handleFileUpload = async (event) => {
|
|
460
|
+
var _a;
|
|
461
|
+
const file = (_a = event.target.files) == null ? void 0 : _a[0];
|
|
462
|
+
if (!file) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
setIsUploading(true);
|
|
466
|
+
try {
|
|
467
|
+
const formData = new FormData();
|
|
468
|
+
formData.append("files", file);
|
|
469
|
+
const response = await fetch("/admin/uploads", {
|
|
470
|
+
method: "POST",
|
|
471
|
+
credentials: "include",
|
|
472
|
+
body: formData
|
|
473
|
+
});
|
|
474
|
+
if (!response.ok) {
|
|
475
|
+
const payload = await response.json().catch(() => null);
|
|
476
|
+
throw new Error((payload == null ? void 0 : payload.message) ?? "File upload failed");
|
|
477
|
+
}
|
|
478
|
+
const result = await response.json();
|
|
479
|
+
if (result.files && result.files.length > 0) {
|
|
480
|
+
const uploadedFile = result.files[0];
|
|
481
|
+
const fileUrl = uploadedFile.url || uploadedFile.key;
|
|
482
|
+
if (fileUrl) {
|
|
483
|
+
onStringChange(descriptor.key, fileUrl);
|
|
484
|
+
toast.success("File uploaded successfully");
|
|
485
|
+
} else {
|
|
486
|
+
throw new Error("File upload succeeded but no URL returned");
|
|
487
|
+
}
|
|
488
|
+
} else {
|
|
489
|
+
throw new Error("File upload failed - no files returned");
|
|
490
|
+
}
|
|
491
|
+
} catch (error) {
|
|
492
|
+
toast.error(
|
|
493
|
+
error instanceof Error ? error.message : "Failed to upload file"
|
|
494
|
+
);
|
|
495
|
+
} finally {
|
|
496
|
+
setIsUploading(false);
|
|
497
|
+
if (fileInputRef.current) {
|
|
498
|
+
fileInputRef.current.value = "";
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
if (descriptor.type === "bool") {
|
|
503
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-x-2", children: [
|
|
504
|
+
/* @__PURE__ */ jsx(
|
|
505
|
+
Switch,
|
|
506
|
+
{
|
|
507
|
+
checked: Boolean(value),
|
|
508
|
+
onCheckedChange: (checked) => onBooleanChange(descriptor.key, Boolean(checked)),
|
|
509
|
+
"aria-label": `Toggle ${descriptor.label ?? descriptor.key}`
|
|
510
|
+
}
|
|
511
|
+
),
|
|
512
|
+
/* @__PURE__ */ jsx(Text, { className: "txt-compact-small text-ui-fg-muted", children: Boolean(value) ? "True" : "False" })
|
|
513
|
+
] });
|
|
514
|
+
}
|
|
515
|
+
if (descriptor.type === "text") {
|
|
516
|
+
return /* @__PURE__ */ jsx(
|
|
517
|
+
Textarea,
|
|
518
|
+
{
|
|
519
|
+
value: value ?? "",
|
|
520
|
+
placeholder: "Enter text",
|
|
521
|
+
rows: 3,
|
|
522
|
+
onChange: (event) => onStringChange(descriptor.key, event.target.value)
|
|
523
|
+
}
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
if (descriptor.type === "number") {
|
|
527
|
+
return /* @__PURE__ */ jsx(
|
|
528
|
+
Input,
|
|
529
|
+
{
|
|
530
|
+
type: "text",
|
|
531
|
+
inputMode: "decimal",
|
|
532
|
+
placeholder: "0.00",
|
|
533
|
+
value: value ?? "",
|
|
534
|
+
onChange: (event) => onStringChange(descriptor.key, event.target.value)
|
|
535
|
+
}
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-y-2", children: [
|
|
539
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-x-2", children: [
|
|
540
|
+
/* @__PURE__ */ jsx(
|
|
541
|
+
Input,
|
|
542
|
+
{
|
|
543
|
+
type: "url",
|
|
544
|
+
placeholder: "https://example.com/file",
|
|
545
|
+
value: value ?? "",
|
|
546
|
+
onChange: (event) => onStringChange(descriptor.key, event.target.value),
|
|
547
|
+
className: "flex-1"
|
|
548
|
+
}
|
|
549
|
+
),
|
|
550
|
+
/* @__PURE__ */ jsx(
|
|
551
|
+
"input",
|
|
552
|
+
{
|
|
553
|
+
ref: fileInputRef,
|
|
554
|
+
type: "file",
|
|
555
|
+
className: "hidden",
|
|
556
|
+
onChange: handleFileUpload,
|
|
557
|
+
disabled: isUploading,
|
|
558
|
+
"aria-label": `Upload file for ${descriptor.label ?? descriptor.key}`
|
|
559
|
+
}
|
|
560
|
+
),
|
|
561
|
+
/* @__PURE__ */ jsx(
|
|
562
|
+
Button,
|
|
563
|
+
{
|
|
564
|
+
type: "button",
|
|
565
|
+
variant: "secondary",
|
|
566
|
+
size: "small",
|
|
567
|
+
onClick: () => {
|
|
568
|
+
var _a;
|
|
569
|
+
return (_a = fileInputRef.current) == null ? void 0 : _a.click();
|
|
570
|
+
},
|
|
571
|
+
disabled: isUploading,
|
|
572
|
+
isLoading: isUploading,
|
|
573
|
+
children: isUploading ? "Uploading..." : "Upload"
|
|
574
|
+
}
|
|
575
|
+
)
|
|
576
|
+
] }),
|
|
577
|
+
typeof value === "string" && value && /* @__PURE__ */ jsx(
|
|
578
|
+
"a",
|
|
579
|
+
{
|
|
580
|
+
className: "txt-compact-small-plus text-ui-fg-interactive underline",
|
|
581
|
+
href: value,
|
|
582
|
+
target: "_blank",
|
|
583
|
+
rel: "noreferrer",
|
|
584
|
+
children: "View file"
|
|
585
|
+
}
|
|
586
|
+
)
|
|
587
|
+
] });
|
|
588
|
+
};
|
|
589
|
+
defineWidgetConfig({
|
|
590
|
+
zone: "product.details.after"
|
|
591
|
+
});
|
|
1
592
|
const i18nTranslations0 = {};
|
|
2
|
-
const widgetModule = { widgets: [
|
|
593
|
+
const widgetModule = { widgets: [
|
|
594
|
+
{
|
|
595
|
+
Component: HideDefaultMetadataWidget,
|
|
596
|
+
zone: ["product.details.side.before"]
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
Component: ProductMetadataTableWidget,
|
|
600
|
+
zone: ["product.details.after"]
|
|
601
|
+
}
|
|
602
|
+
] };
|
|
3
603
|
const routeModule = {
|
|
4
604
|
routes: []
|
|
5
605
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GET = GET;
|
|
4
|
+
const utils_1 = require("@medusajs/framework/utils");
|
|
5
|
+
const product_helper_options_1 = require("../../../config/product-helper-options");
|
|
6
|
+
async function GET(req, res) {
|
|
7
|
+
const configModule = req.scope.resolve(utils_1.ContainerRegistrationKeys.CONFIG_MODULE);
|
|
8
|
+
const options = (0, product_helper_options_1.resolveProductHelperOptions)(configModule);
|
|
9
|
+
res.json({
|
|
10
|
+
metadataDescriptors: options.metadata.descriptors,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicm91dGUuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi8uLi8uLi8uLi9zcmMvYXBpL2FkbWluL3Byb2R1Y3QtbWV0YWRhdGEtY29uZmlnL3JvdXRlLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7O0FBS0Esa0JBVUM7QUFkRCxxREFBcUU7QUFFckUsbUZBQW9GO0FBRTdFLEtBQUssVUFBVSxHQUFHLENBQUMsR0FBa0IsRUFBRSxHQUFtQjtJQUMvRCxNQUFNLFlBQVksR0FBRyxHQUFHLENBQUMsS0FBSyxDQUFDLE9BQU8sQ0FDcEMsaUNBQXlCLENBQUMsYUFBYSxDQUN4QyxDQUFBO0lBRUQsTUFBTSxPQUFPLEdBQUcsSUFBQSxvREFBMkIsRUFBQyxZQUFZLENBQUMsQ0FBQTtJQUV6RCxHQUFHLENBQUMsSUFBSSxDQUFDO1FBQ1AsbUJBQW1CLEVBQUUsT0FBTyxDQUFDLFFBQVEsQ0FBQyxXQUFXO0tBQ2xELENBQUMsQ0FBQTtBQUNKLENBQUMifQ==
|