medusa-product-helper 0.0.4 → 0.0.7
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/.medusa/server/src/admin/index.js +612 -79
- package/.medusa/server/src/admin/index.mjs +612 -79
- package/.medusa/server/src/api/admin/product-metadata-config/route.js +10 -2
- package/.medusa/server/src/config/product-helper-options.js +33 -12
- package/.medusa/server/src/shared/product-metadata/utils.js +5 -1
- package/.medusa/server/src/utils/query-builders/product-filters.js +4 -2
- package/README.md +88 -73
- package/package.json +1 -1
|
@@ -1,61 +1,8 @@
|
|
|
1
|
-
import { defineWidgetConfig } from "@medusajs/admin-sdk";
|
|
2
|
-
import { useEffect, useState, useMemo, useRef } from "react";
|
|
3
1
|
import { jsxs, jsx, Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { defineWidgetConfig } from "@medusajs/admin-sdk";
|
|
4
3
|
import { Container, Heading, Badge, Text, Skeleton, InlineTip, Button, Switch, Textarea, Input, toast } from "@medusajs/ui";
|
|
4
|
+
import { useState, useEffect, useMemo, useRef } from "react";
|
|
5
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
6
|
const METADATA_FIELD_TYPES = ["number", "text", "file", "bool"];
|
|
60
7
|
const VALID_FIELD_TYPES = new Set(METADATA_FIELD_TYPES);
|
|
61
8
|
function normalizeMetadataDescriptors(input) {
|
|
@@ -77,10 +24,12 @@ function normalizeMetadataDescriptors(input) {
|
|
|
77
24
|
continue;
|
|
78
25
|
}
|
|
79
26
|
const label = getNormalizedLabel(item.label);
|
|
27
|
+
const filterable = typeof item.filterable === "boolean" ? item.filterable : Boolean(item.filterable);
|
|
80
28
|
normalized.push({
|
|
81
29
|
key,
|
|
82
30
|
type,
|
|
83
|
-
...label ? { label } : {}
|
|
31
|
+
...label ? { label } : {},
|
|
32
|
+
...filterable ? { filterable: true } : {}
|
|
84
33
|
});
|
|
85
34
|
seenKeys.add(key);
|
|
86
35
|
}
|
|
@@ -236,11 +185,11 @@ function isDeepEqual(a, b) {
|
|
|
236
185
|
}
|
|
237
186
|
const CONFIG_ENDPOINT = "/admin/product-metadata-config";
|
|
238
187
|
const QUERY_KEY = ["medusa-product-helper", "metadata-config"];
|
|
239
|
-
const
|
|
188
|
+
const useMetadataConfig = (entity) => {
|
|
240
189
|
return useQuery({
|
|
241
|
-
queryKey: QUERY_KEY,
|
|
190
|
+
queryKey: [...QUERY_KEY, entity],
|
|
242
191
|
queryFn: async () => {
|
|
243
|
-
const response = await fetch(CONFIG_ENDPOINT
|
|
192
|
+
const response = await fetch(`${CONFIG_ENDPOINT}?entity=${entity}`, {
|
|
244
193
|
credentials: "include"
|
|
245
194
|
});
|
|
246
195
|
if (!response.ok) {
|
|
@@ -252,9 +201,11 @@ const useProductMetadataConfig = () => {
|
|
|
252
201
|
staleTime: 5 * 60 * 1e3
|
|
253
202
|
});
|
|
254
203
|
};
|
|
255
|
-
const
|
|
256
|
-
const
|
|
257
|
-
|
|
204
|
+
const useProductMetadataConfig = () => useMetadataConfig("product");
|
|
205
|
+
const useCategoryMetadataConfig = () => useMetadataConfig("category");
|
|
206
|
+
const CONFIG_DOCS_URL$1 = "https://docs.medusajs.com/admin/extension-points/widgets#product-category-details";
|
|
207
|
+
const CategoryMetadataTableWidget = ({ data }) => {
|
|
208
|
+
const { data: descriptors = [], isPending, isError } = useCategoryMetadataConfig();
|
|
258
209
|
const metadata = (data == null ? void 0 : data.metadata) ?? {};
|
|
259
210
|
const [baselineMetadata, setBaselineMetadata] = useState(metadata);
|
|
260
211
|
const queryClient = useQueryClient();
|
|
@@ -315,7 +266,7 @@ const ProductMetadataTableWidget = ({ data }) => {
|
|
|
315
266
|
values,
|
|
316
267
|
originalMetadata: baselineMetadata
|
|
317
268
|
});
|
|
318
|
-
const response = await fetch(`/admin/
|
|
269
|
+
const response = await fetch(`/admin/product-categories/${data.id}`, {
|
|
319
270
|
method: "POST",
|
|
320
271
|
credentials: "include",
|
|
321
272
|
headers: {
|
|
@@ -330,12 +281,12 @@ const ProductMetadataTableWidget = ({ data }) => {
|
|
|
330
281
|
throw new Error((payload == null ? void 0 : payload.message) ?? "Unable to save metadata");
|
|
331
282
|
}
|
|
332
283
|
const updated = await response.json();
|
|
333
|
-
const nextMetadata = updated.
|
|
284
|
+
const nextMetadata = updated.product_category.metadata;
|
|
334
285
|
setBaselineMetadata(nextMetadata);
|
|
335
286
|
setValues(buildInitialFormState(descriptors, nextMetadata));
|
|
336
287
|
toast.success("Metadata saved");
|
|
337
288
|
await queryClient.invalidateQueries({
|
|
338
|
-
queryKey: ["
|
|
289
|
+
queryKey: ["product-categories"]
|
|
339
290
|
});
|
|
340
291
|
} catch (error) {
|
|
341
292
|
toast.error(error instanceof Error ? error.message : "Save failed");
|
|
@@ -364,7 +315,7 @@ const ProductMetadataTableWidget = ({ data }) => {
|
|
|
364
315
|
"a",
|
|
365
316
|
{
|
|
366
317
|
className: "text-ui-fg-interactive underline",
|
|
367
|
-
href: CONFIG_DOCS_URL,
|
|
318
|
+
href: CONFIG_DOCS_URL$1,
|
|
368
319
|
target: "_blank",
|
|
369
320
|
rel: "noreferrer",
|
|
370
321
|
children: "Learn how to configure it."
|
|
@@ -407,7 +358,7 @@ const ProductMetadataTableWidget = ({ data }) => {
|
|
|
407
358
|
),
|
|
408
359
|
/* @__PURE__ */ jsx("td", { className: "align-top px-4 py-4", children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-y-2", children: [
|
|
409
360
|
/* @__PURE__ */ jsx(
|
|
410
|
-
ValueField,
|
|
361
|
+
ValueField$1,
|
|
411
362
|
{
|
|
412
363
|
descriptor,
|
|
413
364
|
value,
|
|
@@ -421,7 +372,7 @@ const ProductMetadataTableWidget = ({ data }) => {
|
|
|
421
372
|
}) })
|
|
422
373
|
] }) }),
|
|
423
374
|
/* @__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
|
|
375
|
+
/* @__PURE__ */ jsx(Text, { className: "text-ui-fg-muted", children: "Changes are stored on the category metadata object. Clearing a field removes the corresponding key on save." }),
|
|
425
376
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-x-2", children: [
|
|
426
377
|
/* @__PURE__ */ jsx(
|
|
427
378
|
Button,
|
|
@@ -448,7 +399,7 @@ const ProductMetadataTableWidget = ({ data }) => {
|
|
|
448
399
|
] })
|
|
449
400
|
] });
|
|
450
401
|
};
|
|
451
|
-
const ValueField = ({
|
|
402
|
+
const ValueField$1 = ({
|
|
452
403
|
descriptor,
|
|
453
404
|
value,
|
|
454
405
|
onStringChange,
|
|
@@ -587,17 +538,599 @@ const ValueField = ({
|
|
|
587
538
|
] });
|
|
588
539
|
};
|
|
589
540
|
defineWidgetConfig({
|
|
590
|
-
zone: "
|
|
541
|
+
zone: "product_category.details.after"
|
|
591
542
|
});
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
543
|
+
const HideCategoryDefaultMetadataWidget = () => {
|
|
544
|
+
useEffect(() => {
|
|
545
|
+
const hideMetadataSection = () => {
|
|
546
|
+
const headings = document.querySelectorAll("h2");
|
|
547
|
+
headings.forEach((heading) => {
|
|
548
|
+
var _a;
|
|
549
|
+
if (((_a = heading.textContent) == null ? void 0 : _a.trim()) === "Metadata") {
|
|
550
|
+
let container = heading.parentElement;
|
|
551
|
+
while (container && container !== document.body) {
|
|
552
|
+
const hasContainerClass = container.classList.toString().includes("Container");
|
|
553
|
+
const isInSidebar = container.closest('[class*="Sidebar"]') || container.closest('[class*="sidebar"]');
|
|
554
|
+
if (hasContainerClass || isInSidebar) {
|
|
555
|
+
const editLink = container.querySelector('a[href*="metadata/edit"]');
|
|
556
|
+
const badge = container.querySelector('div[class*="Badge"]');
|
|
557
|
+
if (editLink && badge) {
|
|
558
|
+
container.style.display = "none";
|
|
559
|
+
container.setAttribute("data-category-metadata-hidden", "true");
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
container = container.parentElement;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
};
|
|
568
|
+
const runHide = () => {
|
|
569
|
+
setTimeout(hideMetadataSection, 100);
|
|
570
|
+
};
|
|
571
|
+
runHide();
|
|
572
|
+
const observer = new MutationObserver(() => {
|
|
573
|
+
const alreadyHidden = document.querySelector(
|
|
574
|
+
'[data-category-metadata-hidden="true"]'
|
|
575
|
+
);
|
|
576
|
+
if (!alreadyHidden) {
|
|
577
|
+
runHide();
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
observer.observe(document.body, {
|
|
581
|
+
childList: true,
|
|
582
|
+
subtree: true
|
|
583
|
+
});
|
|
584
|
+
return () => {
|
|
585
|
+
observer.disconnect();
|
|
586
|
+
const hidden = document.querySelector(
|
|
587
|
+
'[data-category-metadata-hidden="true"]'
|
|
588
|
+
);
|
|
589
|
+
if (hidden) {
|
|
590
|
+
hidden.style.display = "";
|
|
591
|
+
hidden.removeAttribute("data-category-metadata-hidden");
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
}, []);
|
|
595
|
+
return null;
|
|
596
|
+
};
|
|
597
|
+
defineWidgetConfig({
|
|
598
|
+
zone: "product_category.details.side.before"
|
|
599
|
+
});
|
|
600
|
+
const HideDefaultMetadataWidget = () => {
|
|
601
|
+
useEffect(() => {
|
|
602
|
+
const hideMetadataSection = () => {
|
|
603
|
+
const headings = document.querySelectorAll("h2");
|
|
604
|
+
headings.forEach((heading) => {
|
|
605
|
+
var _a;
|
|
606
|
+
if (((_a = heading.textContent) == null ? void 0 : _a.trim()) === "Metadata") {
|
|
607
|
+
let container = heading.parentElement;
|
|
608
|
+
while (container && container !== document.body) {
|
|
609
|
+
const hasContainerClass = container.classList.toString().includes("Container");
|
|
610
|
+
const isInSidebar = container.closest('[class*="Sidebar"]') || container.closest('[class*="sidebar"]');
|
|
611
|
+
if (hasContainerClass || isInSidebar) {
|
|
612
|
+
const editLink = container.querySelector('a[href*="metadata/edit"]');
|
|
613
|
+
const badge = container.querySelector('div[class*="Badge"]');
|
|
614
|
+
if (editLink && badge) {
|
|
615
|
+
container.style.display = "none";
|
|
616
|
+
container.setAttribute("data-metadata-hidden", "true");
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
container = container.parentElement;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
};
|
|
625
|
+
const runHide = () => {
|
|
626
|
+
setTimeout(hideMetadataSection, 100);
|
|
627
|
+
};
|
|
628
|
+
runHide();
|
|
629
|
+
const observer = new MutationObserver(() => {
|
|
630
|
+
const alreadyHidden = document.querySelector('[data-metadata-hidden="true"]');
|
|
631
|
+
if (!alreadyHidden) {
|
|
632
|
+
runHide();
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
observer.observe(document.body, {
|
|
636
|
+
childList: true,
|
|
637
|
+
subtree: true
|
|
638
|
+
});
|
|
639
|
+
return () => {
|
|
640
|
+
observer.disconnect();
|
|
641
|
+
const hidden = document.querySelector('[data-metadata-hidden="true"]');
|
|
642
|
+
if (hidden) {
|
|
643
|
+
hidden.style.display = "";
|
|
644
|
+
hidden.removeAttribute("data-metadata-hidden");
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
}, []);
|
|
648
|
+
return null;
|
|
649
|
+
};
|
|
650
|
+
defineWidgetConfig({
|
|
651
|
+
zone: "product.details.side.before"
|
|
652
|
+
});
|
|
653
|
+
const CONFIG_DOCS_URL = "https://docs.medusajs.com/admin/extension-points/widgets#product-details";
|
|
654
|
+
const ProductMetadataTableWidget = ({ data }) => {
|
|
655
|
+
const { data: descriptors = [], isPending, isError } = useProductMetadataConfig();
|
|
656
|
+
const metadata = (data == null ? void 0 : data.metadata) ?? {};
|
|
657
|
+
const [baselineMetadata, setBaselineMetadata] = useState(metadata);
|
|
658
|
+
const queryClient = useQueryClient();
|
|
659
|
+
useEffect(() => {
|
|
660
|
+
setBaselineMetadata(metadata);
|
|
661
|
+
}, [metadata]);
|
|
662
|
+
const initialState = useMemo(
|
|
663
|
+
() => buildInitialFormState(descriptors, baselineMetadata),
|
|
664
|
+
[descriptors, baselineMetadata]
|
|
665
|
+
);
|
|
666
|
+
const [values, setValues] = useState(
|
|
667
|
+
initialState
|
|
668
|
+
);
|
|
669
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
670
|
+
useEffect(() => {
|
|
671
|
+
setValues(initialState);
|
|
672
|
+
}, [initialState]);
|
|
673
|
+
const errors = useMemo(() => {
|
|
674
|
+
return descriptors.reduce((acc, descriptor) => {
|
|
675
|
+
const error = validateValueForDescriptor(descriptor, values[descriptor.key]);
|
|
676
|
+
if (error) {
|
|
677
|
+
acc[descriptor.key] = error;
|
|
678
|
+
}
|
|
679
|
+
return acc;
|
|
680
|
+
}, {});
|
|
681
|
+
}, [descriptors, values]);
|
|
682
|
+
const hasErrors = Object.keys(errors).length > 0;
|
|
683
|
+
const isDirty = useMemo(() => {
|
|
684
|
+
return hasMetadataChanges({
|
|
685
|
+
descriptors,
|
|
686
|
+
values,
|
|
687
|
+
originalMetadata: baselineMetadata
|
|
688
|
+
});
|
|
689
|
+
}, [descriptors, values, baselineMetadata]);
|
|
690
|
+
const handleStringChange = (key, nextValue) => {
|
|
691
|
+
setValues((prev) => ({
|
|
692
|
+
...prev,
|
|
693
|
+
[key]: nextValue
|
|
694
|
+
}));
|
|
695
|
+
};
|
|
696
|
+
const handleBooleanChange = (key, nextValue) => {
|
|
697
|
+
setValues((prev) => ({
|
|
698
|
+
...prev,
|
|
699
|
+
[key]: nextValue
|
|
700
|
+
}));
|
|
701
|
+
};
|
|
702
|
+
const handleReset = () => {
|
|
703
|
+
setValues(initialState);
|
|
704
|
+
};
|
|
705
|
+
const handleSubmit = async () => {
|
|
706
|
+
if (!(data == null ? void 0 : data.id) || !descriptors.length) {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
setIsSaving(true);
|
|
710
|
+
try {
|
|
711
|
+
const metadataPayload = buildMetadataPayload({
|
|
712
|
+
descriptors,
|
|
713
|
+
values,
|
|
714
|
+
originalMetadata: baselineMetadata
|
|
715
|
+
});
|
|
716
|
+
const response = await fetch(`/admin/products/${data.id}`, {
|
|
717
|
+
method: "POST",
|
|
718
|
+
credentials: "include",
|
|
719
|
+
headers: {
|
|
720
|
+
"Content-Type": "application/json"
|
|
721
|
+
},
|
|
722
|
+
body: JSON.stringify({
|
|
723
|
+
metadata: metadataPayload
|
|
724
|
+
})
|
|
725
|
+
});
|
|
726
|
+
if (!response.ok) {
|
|
727
|
+
const payload = await response.json().catch(() => null);
|
|
728
|
+
throw new Error((payload == null ? void 0 : payload.message) ?? "Unable to save metadata");
|
|
729
|
+
}
|
|
730
|
+
const updated = await response.json();
|
|
731
|
+
const nextMetadata = updated.product.metadata;
|
|
732
|
+
setBaselineMetadata(nextMetadata);
|
|
733
|
+
setValues(buildInitialFormState(descriptors, nextMetadata));
|
|
734
|
+
toast.success("Metadata saved");
|
|
735
|
+
await queryClient.invalidateQueries({
|
|
736
|
+
queryKey: ["products"]
|
|
737
|
+
});
|
|
738
|
+
} catch (error) {
|
|
739
|
+
toast.error(error instanceof Error ? error.message : "Save failed");
|
|
740
|
+
} finally {
|
|
741
|
+
setIsSaving(false);
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
return /* @__PURE__ */ jsxs(Container, { className: "flex flex-col gap-y-4", children: [
|
|
745
|
+
/* @__PURE__ */ jsxs("header", { className: "flex flex-col gap-y-1", children: [
|
|
746
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-x-3", children: [
|
|
747
|
+
/* @__PURE__ */ jsx(Heading, { level: "h2", children: "Metadata" }),
|
|
748
|
+
/* @__PURE__ */ jsx(Badge, { size: "2xsmall", rounded: "full", children: descriptors.length })
|
|
749
|
+
] }),
|
|
750
|
+
/* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", children: "Structured metadata mapped to the keys you configured in the plugin options." })
|
|
751
|
+
] }),
|
|
752
|
+
isPending ? /* @__PURE__ */ jsx(Skeleton, { className: "h-[160px] w-full" }) : isError ? /* @__PURE__ */ jsxs(InlineTip, { variant: "error", label: "Configuration unavailable", children: [
|
|
753
|
+
"Unable to load metadata configuration for this plugin. Confirm that the plugin is registered with options in ",
|
|
754
|
+
/* @__PURE__ */ jsx("code", { children: "medusa-config.ts" }),
|
|
755
|
+
"."
|
|
756
|
+
] }) : !descriptors.length ? /* @__PURE__ */ jsxs(InlineTip, { variant: "info", label: "No configured metadata keys", children: [
|
|
757
|
+
"Provide a ",
|
|
758
|
+
/* @__PURE__ */ jsx("code", { children: "metadataDescriptors" }),
|
|
759
|
+
" array in the plugin options to control which keys show up here.",
|
|
760
|
+
" ",
|
|
761
|
+
/* @__PURE__ */ jsx(
|
|
762
|
+
"a",
|
|
763
|
+
{
|
|
764
|
+
className: "text-ui-fg-interactive underline",
|
|
765
|
+
href: CONFIG_DOCS_URL,
|
|
766
|
+
target: "_blank",
|
|
767
|
+
rel: "noreferrer",
|
|
768
|
+
children: "Learn how to configure it."
|
|
769
|
+
}
|
|
770
|
+
)
|
|
771
|
+
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
772
|
+
/* @__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: [
|
|
773
|
+
/* @__PURE__ */ jsx("thead", { className: "bg-ui-bg-subtle", children: /* @__PURE__ */ jsxs("tr", { children: [
|
|
774
|
+
/* @__PURE__ */ jsx(
|
|
775
|
+
"th",
|
|
776
|
+
{
|
|
777
|
+
scope: "col",
|
|
778
|
+
className: "txt-compact-xsmall-plus text-left uppercase tracking-wide text-ui-fg-muted px-4 py-3",
|
|
779
|
+
children: "Label"
|
|
780
|
+
}
|
|
781
|
+
),
|
|
782
|
+
/* @__PURE__ */ jsx(
|
|
783
|
+
"th",
|
|
784
|
+
{
|
|
785
|
+
scope: "col",
|
|
786
|
+
className: "txt-compact-xsmall-plus text-left uppercase tracking-wide text-ui-fg-muted px-4 py-3",
|
|
787
|
+
children: "Value"
|
|
788
|
+
}
|
|
789
|
+
)
|
|
790
|
+
] }) }),
|
|
791
|
+
/* @__PURE__ */ jsx("tbody", { className: "divide-y divide-ui-border-subtle bg-ui-bg-base", children: descriptors.map((descriptor) => {
|
|
792
|
+
const value = values[descriptor.key];
|
|
793
|
+
const error = errors[descriptor.key];
|
|
794
|
+
return /* @__PURE__ */ jsxs("tr", { children: [
|
|
795
|
+
/* @__PURE__ */ jsx(
|
|
796
|
+
"th",
|
|
797
|
+
{
|
|
798
|
+
scope: "row",
|
|
799
|
+
className: "txt-compact-medium text-ui-fg-base align-top px-4 py-4",
|
|
800
|
+
children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-y-1", children: [
|
|
801
|
+
/* @__PURE__ */ jsx("span", { children: descriptor.label ?? descriptor.key }),
|
|
802
|
+
/* @__PURE__ */ jsx("span", { className: "txt-compact-xsmall-plus text-ui-fg-muted uppercase tracking-wide", children: descriptor.type })
|
|
803
|
+
] })
|
|
804
|
+
}
|
|
805
|
+
),
|
|
806
|
+
/* @__PURE__ */ jsx("td", { className: "align-top px-4 py-4", children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-y-2", children: [
|
|
807
|
+
/* @__PURE__ */ jsx(
|
|
808
|
+
ValueField,
|
|
809
|
+
{
|
|
810
|
+
descriptor,
|
|
811
|
+
value,
|
|
812
|
+
onStringChange: handleStringChange,
|
|
813
|
+
onBooleanChange: handleBooleanChange
|
|
814
|
+
}
|
|
815
|
+
),
|
|
816
|
+
error && /* @__PURE__ */ jsx(Text, { className: "txt-compact-small text-ui-fg-error", children: error })
|
|
817
|
+
] }) })
|
|
818
|
+
] }, descriptor.key);
|
|
819
|
+
}) })
|
|
820
|
+
] }) }),
|
|
821
|
+
/* @__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: [
|
|
822
|
+
/* @__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." }),
|
|
823
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-x-2", children: [
|
|
824
|
+
/* @__PURE__ */ jsx(
|
|
825
|
+
Button,
|
|
826
|
+
{
|
|
827
|
+
variant: "secondary",
|
|
828
|
+
size: "small",
|
|
829
|
+
disabled: !isDirty || isSaving,
|
|
830
|
+
onClick: handleReset,
|
|
831
|
+
children: "Reset"
|
|
832
|
+
}
|
|
833
|
+
),
|
|
834
|
+
/* @__PURE__ */ jsx(
|
|
835
|
+
Button,
|
|
836
|
+
{
|
|
837
|
+
size: "small",
|
|
838
|
+
onClick: handleSubmit,
|
|
839
|
+
disabled: !isDirty || hasErrors || isSaving,
|
|
840
|
+
isLoading: isSaving,
|
|
841
|
+
children: "Save metadata"
|
|
842
|
+
}
|
|
843
|
+
)
|
|
844
|
+
] })
|
|
845
|
+
] })
|
|
846
|
+
] })
|
|
847
|
+
] });
|
|
848
|
+
};
|
|
849
|
+
const ValueField = ({
|
|
850
|
+
descriptor,
|
|
851
|
+
value,
|
|
852
|
+
onStringChange,
|
|
853
|
+
onBooleanChange
|
|
854
|
+
}) => {
|
|
855
|
+
const fileInputRef = useRef(null);
|
|
856
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
857
|
+
const handleFileUpload = async (event) => {
|
|
858
|
+
var _a;
|
|
859
|
+
const file = (_a = event.target.files) == null ? void 0 : _a[0];
|
|
860
|
+
if (!file) {
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
setIsUploading(true);
|
|
864
|
+
try {
|
|
865
|
+
const formData = new FormData();
|
|
866
|
+
formData.append("files", file);
|
|
867
|
+
const response = await fetch("/admin/uploads", {
|
|
868
|
+
method: "POST",
|
|
869
|
+
credentials: "include",
|
|
870
|
+
body: formData
|
|
871
|
+
});
|
|
872
|
+
if (!response.ok) {
|
|
873
|
+
const payload = await response.json().catch(() => null);
|
|
874
|
+
throw new Error((payload == null ? void 0 : payload.message) ?? "File upload failed");
|
|
875
|
+
}
|
|
876
|
+
const result = await response.json();
|
|
877
|
+
if (result.files && result.files.length > 0) {
|
|
878
|
+
const uploadedFile = result.files[0];
|
|
879
|
+
const fileUrl = uploadedFile.url || uploadedFile.key;
|
|
880
|
+
if (fileUrl) {
|
|
881
|
+
onStringChange(descriptor.key, fileUrl);
|
|
882
|
+
toast.success("File uploaded successfully");
|
|
883
|
+
} else {
|
|
884
|
+
throw new Error("File upload succeeded but no URL returned");
|
|
885
|
+
}
|
|
886
|
+
} else {
|
|
887
|
+
throw new Error("File upload failed - no files returned");
|
|
888
|
+
}
|
|
889
|
+
} catch (error) {
|
|
890
|
+
toast.error(
|
|
891
|
+
error instanceof Error ? error.message : "Failed to upload file"
|
|
892
|
+
);
|
|
893
|
+
} finally {
|
|
894
|
+
setIsUploading(false);
|
|
895
|
+
if (fileInputRef.current) {
|
|
896
|
+
fileInputRef.current.value = "";
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
if (descriptor.type === "bool") {
|
|
901
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-x-2", children: [
|
|
902
|
+
/* @__PURE__ */ jsx(
|
|
903
|
+
Switch,
|
|
904
|
+
{
|
|
905
|
+
checked: Boolean(value),
|
|
906
|
+
onCheckedChange: (checked) => onBooleanChange(descriptor.key, Boolean(checked)),
|
|
907
|
+
"aria-label": `Toggle ${descriptor.label ?? descriptor.key}`
|
|
908
|
+
}
|
|
909
|
+
),
|
|
910
|
+
/* @__PURE__ */ jsx(Text, { className: "txt-compact-small text-ui-fg-muted", children: Boolean(value) ? "True" : "False" })
|
|
911
|
+
] });
|
|
912
|
+
}
|
|
913
|
+
if (descriptor.type === "text") {
|
|
914
|
+
return /* @__PURE__ */ jsx(
|
|
915
|
+
Textarea,
|
|
916
|
+
{
|
|
917
|
+
value: value ?? "",
|
|
918
|
+
placeholder: "Enter text",
|
|
919
|
+
rows: 3,
|
|
920
|
+
onChange: (event) => onStringChange(descriptor.key, event.target.value)
|
|
921
|
+
}
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
if (descriptor.type === "number") {
|
|
925
|
+
return /* @__PURE__ */ jsx(
|
|
926
|
+
Input,
|
|
927
|
+
{
|
|
928
|
+
type: "text",
|
|
929
|
+
inputMode: "decimal",
|
|
930
|
+
placeholder: "0.00",
|
|
931
|
+
value: value ?? "",
|
|
932
|
+
onChange: (event) => onStringChange(descriptor.key, event.target.value)
|
|
933
|
+
}
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-y-2", children: [
|
|
937
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-x-2", children: [
|
|
938
|
+
/* @__PURE__ */ jsx(
|
|
939
|
+
Input,
|
|
940
|
+
{
|
|
941
|
+
type: "url",
|
|
942
|
+
placeholder: "https://example.com/file",
|
|
943
|
+
value: value ?? "",
|
|
944
|
+
onChange: (event) => onStringChange(descriptor.key, event.target.value),
|
|
945
|
+
className: "flex-1"
|
|
946
|
+
}
|
|
947
|
+
),
|
|
948
|
+
/* @__PURE__ */ jsx(
|
|
949
|
+
"input",
|
|
950
|
+
{
|
|
951
|
+
ref: fileInputRef,
|
|
952
|
+
type: "file",
|
|
953
|
+
className: "hidden",
|
|
954
|
+
onChange: handleFileUpload,
|
|
955
|
+
disabled: isUploading,
|
|
956
|
+
"aria-label": `Upload file for ${descriptor.label ?? descriptor.key}`
|
|
957
|
+
}
|
|
958
|
+
),
|
|
959
|
+
/* @__PURE__ */ jsx(
|
|
960
|
+
Button,
|
|
961
|
+
{
|
|
962
|
+
type: "button",
|
|
963
|
+
variant: "secondary",
|
|
964
|
+
size: "small",
|
|
965
|
+
onClick: () => {
|
|
966
|
+
var _a;
|
|
967
|
+
return (_a = fileInputRef.current) == null ? void 0 : _a.click();
|
|
968
|
+
},
|
|
969
|
+
disabled: isUploading,
|
|
970
|
+
isLoading: isUploading,
|
|
971
|
+
children: isUploading ? "Uploading..." : "Upload"
|
|
972
|
+
}
|
|
973
|
+
)
|
|
974
|
+
] }),
|
|
975
|
+
typeof value === "string" && value && /* @__PURE__ */ jsx(
|
|
976
|
+
"a",
|
|
977
|
+
{
|
|
978
|
+
className: "txt-compact-small-plus text-ui-fg-interactive underline",
|
|
979
|
+
href: value,
|
|
980
|
+
target: "_blank",
|
|
981
|
+
rel: "noreferrer",
|
|
982
|
+
children: "View file"
|
|
983
|
+
}
|
|
984
|
+
)
|
|
985
|
+
] });
|
|
986
|
+
};
|
|
987
|
+
defineWidgetConfig({
|
|
988
|
+
zone: "product.details.after"
|
|
989
|
+
});
|
|
990
|
+
const fetchJson = async (path) => {
|
|
991
|
+
const response = await fetch(path, {
|
|
992
|
+
credentials: "include"
|
|
993
|
+
});
|
|
994
|
+
const payload = await response.json().catch(() => null);
|
|
995
|
+
if (!response.ok) {
|
|
996
|
+
throw new Error(
|
|
997
|
+
(payload == null ? void 0 : payload.message) ?? "Unable to load wishlist statistics from the server"
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
return payload;
|
|
1001
|
+
};
|
|
1002
|
+
const ProductWishlistStatsWidget = ({ data }) => {
|
|
1003
|
+
const productId = data == null ? void 0 : data.id;
|
|
1004
|
+
const {
|
|
1005
|
+
data: productStats,
|
|
1006
|
+
isPending: isProductStatsPending,
|
|
1007
|
+
isError: isProductStatsError,
|
|
1008
|
+
error: productStatsError
|
|
1009
|
+
} = useQuery({
|
|
1010
|
+
queryKey: ["wishlist", "product", productId],
|
|
1011
|
+
enabled: Boolean(productId),
|
|
1012
|
+
queryFn: () => fetchJson(
|
|
1013
|
+
`/admin/wishlist/stats?product_id=${productId}`
|
|
1014
|
+
),
|
|
1015
|
+
refetchInterval: 6e4
|
|
1016
|
+
});
|
|
1017
|
+
const {
|
|
1018
|
+
data: allStats,
|
|
1019
|
+
isPending: isAllStatsPending,
|
|
1020
|
+
isError: isAllStatsError,
|
|
1021
|
+
error: allStatsError
|
|
1022
|
+
} = useQuery({
|
|
1023
|
+
queryKey: ["wishlist", "stats"],
|
|
1024
|
+
queryFn: () => fetchJson("/admin/wishlist/stats"),
|
|
1025
|
+
staleTime: 6e4
|
|
1026
|
+
});
|
|
1027
|
+
const topFive = useMemo(() => {
|
|
1028
|
+
var _a;
|
|
1029
|
+
if (!((_a = allStats == null ? void 0 : allStats.stats) == null ? void 0 : _a.length)) {
|
|
1030
|
+
return [];
|
|
1031
|
+
}
|
|
1032
|
+
return [...allStats.stats].sort((a, b) => b.wishlist_count - a.wishlist_count).slice(0, 5);
|
|
1033
|
+
}, [allStats]);
|
|
1034
|
+
const productWishlistCount = (productStats == null ? void 0 : productStats.wishlist_count) ?? 0;
|
|
1035
|
+
const productIsTopWishlisted = topFive.some(
|
|
1036
|
+
(stat) => stat.product_id === productId
|
|
1037
|
+
);
|
|
1038
|
+
return /* @__PURE__ */ jsxs(Container, { className: "flex flex-col gap-y-4", children: [
|
|
1039
|
+
/* @__PURE__ */ jsxs("header", { className: "flex flex-col gap-y-1", children: [
|
|
1040
|
+
/* @__PURE__ */ jsx(Heading, { level: "h2", children: "Wishlist performance" }),
|
|
1041
|
+
/* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", children: "Track how often this product appears in customer wishlists and see the current top performers." })
|
|
1042
|
+
] }),
|
|
1043
|
+
!productId ? /* @__PURE__ */ jsx(InlineTip, { variant: "info", label: "Product not loaded yet", children: "Open a product detail record to view wishlist insights." }) : isProductStatsPending ? /* @__PURE__ */ jsx(Skeleton, { className: "h-[96px] w-full rounded-lg" }) : isProductStatsError ? /* @__PURE__ */ jsx(InlineTip, { variant: "error", label: "Unable to load product stats", children: productStatsError instanceof Error ? productStatsError.message : "Unknown error" }) : /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-y-2 rounded-lg border border-ui-border-base bg-ui-bg-base p-4", children: [
|
|
1044
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-x-2", children: [
|
|
1045
|
+
/* @__PURE__ */ jsx(Text, { className: "txt-compact-xsmall-plus uppercase tracking-wide text-ui-fg-muted", children: "This product" }),
|
|
1046
|
+
productIsTopWishlisted && /* @__PURE__ */ jsx(Badge, { size: "2xsmall", rounded: "full", color: "green", children: "Top 5" })
|
|
1047
|
+
] }),
|
|
1048
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-y-1", children: [
|
|
1049
|
+
/* @__PURE__ */ jsx(Heading, { level: "h1", className: "text-[32px] leading-none", children: productWishlistCount }),
|
|
1050
|
+
/* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", children: productWishlistCount === 1 ? "customer has saved this product." : "customers have saved this product." })
|
|
1051
|
+
] })
|
|
1052
|
+
] }),
|
|
1053
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-y-3 rounded-lg border border-ui-border-base bg-ui-bg-subtle p-4", children: [
|
|
1054
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-x-4", children: [
|
|
1055
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
1056
|
+
/* @__PURE__ */ jsx(Text, { className: "txt-compact-xsmall-plus uppercase tracking-wide text-ui-fg-muted", children: "Storewide insights" }),
|
|
1057
|
+
/* @__PURE__ */ jsx(Heading, { level: "h3", children: "Top wishlisted products" })
|
|
1058
|
+
] }),
|
|
1059
|
+
productId && /* @__PURE__ */ jsx(Badge, { size: "2xsmall", color: "grey", children: productIsTopWishlisted ? "In top 5" : "Not in top 5" })
|
|
1060
|
+
] }),
|
|
1061
|
+
isAllStatsPending ? /* @__PURE__ */ jsx(Skeleton, { className: "h-[160px] w-full rounded-lg" }) : isAllStatsError ? /* @__PURE__ */ jsx(InlineTip, { variant: "error", label: "Unable to load storewide stats", children: allStatsError instanceof Error ? allStatsError.message : "Unknown error" }) : !topFive.length ? /* @__PURE__ */ jsx(InlineTip, { variant: "info", label: "No wishlist activity yet", children: "Customers have not saved any products to their wishlists yet. Once they do, the most popular products will show up here." }) : /* @__PURE__ */ jsx("div", { className: "overflow-hidden rounded-lg border border-ui-border-base bg-ui-bg-base", children: /* @__PURE__ */ jsxs("table", { className: "min-w-full divide-y divide-ui-border-base", children: [
|
|
1062
|
+
/* @__PURE__ */ jsx("thead", { className: "bg-ui-bg-field", children: /* @__PURE__ */ jsxs("tr", { children: [
|
|
1063
|
+
/* @__PURE__ */ jsx(
|
|
1064
|
+
"th",
|
|
1065
|
+
{
|
|
1066
|
+
scope: "col",
|
|
1067
|
+
className: "px-4 py-2 text-left text-[11px] font-semibold uppercase tracking-wide text-ui-fg-muted",
|
|
1068
|
+
children: "#"
|
|
1069
|
+
}
|
|
1070
|
+
),
|
|
1071
|
+
/* @__PURE__ */ jsx(
|
|
1072
|
+
"th",
|
|
1073
|
+
{
|
|
1074
|
+
scope: "col",
|
|
1075
|
+
className: "px-4 py-2 text-left text-[11px] font-semibold uppercase tracking-wide text-ui-fg-muted",
|
|
1076
|
+
children: "Product ID"
|
|
1077
|
+
}
|
|
1078
|
+
),
|
|
1079
|
+
/* @__PURE__ */ jsx(
|
|
1080
|
+
"th",
|
|
1081
|
+
{
|
|
1082
|
+
scope: "col",
|
|
1083
|
+
className: "px-4 py-2 text-right text-[11px] font-semibold uppercase tracking-wide text-ui-fg-muted",
|
|
1084
|
+
children: "Wishlists"
|
|
1085
|
+
}
|
|
1086
|
+
)
|
|
1087
|
+
] }) }),
|
|
1088
|
+
/* @__PURE__ */ jsx("tbody", { className: "divide-y divide-ui-border-subtle", children: topFive.map((stat, index) => /* @__PURE__ */ jsxs(
|
|
1089
|
+
"tr",
|
|
1090
|
+
{
|
|
1091
|
+
className: stat.product_id === productId ? "bg-ui-bg-subtle" : "bg-ui-bg-base",
|
|
1092
|
+
children: [
|
|
1093
|
+
/* @__PURE__ */ jsxs("td", { className: "px-4 py-3 text-sm font-medium text-ui-fg-subtle", children: [
|
|
1094
|
+
"#",
|
|
1095
|
+
index + 1
|
|
1096
|
+
] }),
|
|
1097
|
+
/* @__PURE__ */ jsx("td", { className: "px-4 py-3", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-x-2", children: [
|
|
1098
|
+
/* @__PURE__ */ jsx(Text, { className: "font-mono text-sm", children: stat.product_id }),
|
|
1099
|
+
stat.product_id === productId && /* @__PURE__ */ jsx(Badge, { size: "2xsmall", color: "green", children: "Current" })
|
|
1100
|
+
] }) }),
|
|
1101
|
+
/* @__PURE__ */ jsx("td", { className: "px-4 py-3 text-right text-sm font-semibold", children: stat.wishlist_count.toLocaleString() })
|
|
1102
|
+
]
|
|
1103
|
+
},
|
|
1104
|
+
stat.product_id
|
|
1105
|
+
)) })
|
|
1106
|
+
] }) })
|
|
1107
|
+
] })
|
|
1108
|
+
] });
|
|
1109
|
+
};
|
|
1110
|
+
defineWidgetConfig({
|
|
1111
|
+
zone: "product.details.side.after"
|
|
1112
|
+
});
|
|
1113
|
+
const i18nTranslations0 = {};
|
|
1114
|
+
const widgetModule = { widgets: [
|
|
1115
|
+
{
|
|
1116
|
+
Component: CategoryMetadataTableWidget,
|
|
1117
|
+
zone: ["product_category.details.after"]
|
|
1118
|
+
},
|
|
1119
|
+
{
|
|
1120
|
+
Component: HideCategoryDefaultMetadataWidget,
|
|
1121
|
+
zone: ["product_category.details.side.before"]
|
|
1122
|
+
},
|
|
1123
|
+
{
|
|
1124
|
+
Component: HideDefaultMetadataWidget,
|
|
1125
|
+
zone: ["product.details.side.before"]
|
|
1126
|
+
},
|
|
1127
|
+
{
|
|
1128
|
+
Component: ProductMetadataTableWidget,
|
|
1129
|
+
zone: ["product.details.after"]
|
|
1130
|
+
},
|
|
1131
|
+
{
|
|
1132
|
+
Component: ProductWishlistStatsWidget,
|
|
1133
|
+
zone: ["product.details.side.after"]
|
|
601
1134
|
}
|
|
602
1135
|
] };
|
|
603
1136
|
const routeModule = {
|