@lambdacurry/medusa-product-reviews
Version:
Product Reviews Plugin for Medusa
443 lines (442 loc) • 19.1 kB
JavaScript
import { jsx, jsxs, Fragment } from "react/jsx-runtime";
import { defineWidgetConfig } from "@medusajs/admin-sdk";
import { Container as Container$1, clx, Drawer, Label, Textarea, Text, Button, createDataTableColumnHelper, useDataTable, DataTable, Heading } from "@medusajs/ui";
import { Eye, ChatBubble, CheckCircle } from "@medusajs/icons";
import { DateTime } from "luxon";
import { useState } from "react";
import { useQueryClient, useMutation, useQuery } from "@tanstack/react-query";
import { MedusaPluginsSDK } from "@lambdacurry/medusa-plugins-sdk";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Link } from "react-router-dom";
const Container = (props) => {
return /* @__PURE__ */ jsx(Container$1, { ...props, className: clx("divide-y p-0", props.className) });
};
const backendUrl = __BACKEND_URL__ ?? "http://localhost:9000";
const sdk = new MedusaPluginsSDK({
baseUrl: backendUrl,
auth: {
type: "session"
}
});
const QUERY_KEY = ["product-reviews"];
const useAdminListProductReviews = (query) => {
return useQuery({
queryKey: [...QUERY_KEY, query],
placeholderData: (previousData) => previousData,
queryFn: async () => {
return sdk.admin.productReviews.list(query);
}
});
};
const useAdminCreateProductReviewResponseMutation = (reviewId) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body) => {
return await sdk.admin.productReviews.createResponse(reviewId, body);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
}
});
};
const useAdminUpdateProductReviewResponseMutation = (reviewId) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body) => {
return await sdk.admin.productReviews.updateResponse(reviewId, body);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
}
});
};
const useAdminUpdateProductReviewStatusMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
reviewId,
status
}) => {
return await sdk.admin.productReviews.updateStatus(reviewId, status);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
}
});
};
z.object({
content: z.string().min(1).refine((val) => val.trim().split(/\s+/).length >= 1, {
message: "Response must contain at least one word"
})
});
const ProductReviewResponseDrawer = ({
review,
open,
setOpen
}) => {
var _a;
const title = (review == null ? void 0 : review.response) ? "Edit Response" : "Add Response";
const { mutate: createResponse } = useAdminCreateProductReviewResponseMutation((review == null ? void 0 : review.id) ?? "");
const { mutate: updateResponse } = useAdminUpdateProductReviewResponseMutation((review == null ? void 0 : review.id) ?? "");
const form = useForm({
defaultValues: {
content: ((_a = review == null ? void 0 : review.response) == null ? void 0 : _a.content) ?? ""
}
// resolver: zodResolver(schema),
});
if (!review) return null;
const onSubmit = async (data) => {
if (review.response) {
await updateResponse(data);
} else {
await createResponse(data);
}
setOpen(false);
};
return /* @__PURE__ */ jsx(Drawer, { open, onOpenChange: setOpen, children: /* @__PURE__ */ jsxs(Drawer.Content, { children: [
/* @__PURE__ */ jsx(Drawer.Header, { children: /* @__PURE__ */ jsx(Drawer.Title, { className: "font-medium", children: title }) }),
/* @__PURE__ */ jsx(Drawer.Body, { className: "p-4", children: /* @__PURE__ */ jsx("form", { onSubmit: form.handleSubmit(onSubmit), className: "flex flex-col gap-4", children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
/* @__PURE__ */ jsx(Label, { className: "font-medium", children: "Response" }),
/* @__PURE__ */ jsx(Textarea, { placeholder: "Write your response...", rows: 4, ...form.register("content") }),
form.formState.errors.content && /* @__PURE__ */ jsx(Text, { className: "text-red-500", size: "small", children: form.formState.errors.content.message })
] }) }) }),
/* @__PURE__ */ jsxs(Drawer.Footer, { children: [
/* @__PURE__ */ jsx(Drawer.Close, { asChild: true, children: /* @__PURE__ */ jsx(Button, { variant: "secondary", children: "Cancel" }) }),
/* @__PURE__ */ jsx(Button, { onClick: form.handleSubmit(onSubmit), children: "Save" })
] })
] }) });
};
const ReviewStars = ({ rating }) => {
return /* @__PURE__ */ jsx("div", { className: "flex gap-0.5", children: [...Array(5)].map((_, index) => /* @__PURE__ */ jsx(
"svg",
{
className: `h-5 w-5 ${index < rating ? "text-yellow-400" : "text-gray-200"}`,
xmlns: "http://www.w3.org/2000/svg",
viewBox: "0 0 24 24",
fill: "currentColor",
children: /* @__PURE__ */ jsx(
"path",
{
fillRule: "evenodd",
d: "M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z",
clipRule: "evenodd"
}
)
},
index
)) });
};
const SectionRow = ({ title, value, actions }) => {
const isValueString = typeof value === "string" || !value;
return /* @__PURE__ */ jsxs(
"div",
{
className: clx("text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4", {
"grid-cols-[1fr_1fr_28px]": !!actions
}),
children: [
/* @__PURE__ */ jsx(Text, { size: "small", weight: "plus", leading: "compact", children: title }),
isValueString ? /* @__PURE__ */ jsx(Text, { size: "small", leading: "compact", className: "whitespace-pre-line text-pretty", children: value ?? "-" }) : /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1", children: value }),
actions && /* @__PURE__ */ jsx("div", { children: actions })
]
}
);
};
const ProductReviewDetailsDrawer = ({
review,
open,
setOpen
}) => {
var _a;
if (!review) return null;
const ProductValue = () => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4", children: [
review.product.thumbnail ? /* @__PURE__ */ jsx(
"img",
{
className: "h-12 w-12 flex-shrink-0 rounded-md",
src: review.product.thumbnail,
alt: review.product.title
}
) : /* @__PURE__ */ jsx("div", { className: "h-12 w-12 flex-shrink-0 rounded-md bg-gray-200" }),
/* @__PURE__ */ jsx(Link, { to: `/products/${review.product.id}`, children: /* @__PURE__ */ jsx(Text, { className: "hover:underline", children: review.product.title }) })
] });
const OrderValue = () => /* @__PURE__ */ jsx(Link, { to: `/orders/${review.order.id}`, children: /* @__PURE__ */ jsxs(Text, { className: "hover:underline", children: [
"#",
review.order.display_id
] }) });
const StatusValue = () => /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle text-sm", children: review.status });
const CreatedAtValue = () => /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle text-sm", children: DateTime.fromISO(review.created_at).toFormat("LLL dd yyyy hh:mm a") });
const CustomerValue = () => /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-1", children: /* @__PURE__ */ jsx(Text, { children: review.name }) });
const ReviewContent = () => /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-2", children: /* @__PURE__ */ jsx(Text, { className: "whitespace-pre-wrap", children: review.content }) });
const ImagesValue = () => /* @__PURE__ */ jsx("div", { className: "grid grid-cols-3 gap-2", children: review.images.map((image, index) => /* @__PURE__ */ jsx(
"img",
{
src: image.url,
alt: `Review image ${index + 1}`,
className: "w-full h-full object-cover"
}
)) });
const ResponseValue = () => {
var _a2;
return /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-1", children: /* @__PURE__ */ jsx(Text, { className: "whitespace-pre-wrap", children: (_a2 = review.response) == null ? void 0 : _a2.content }) });
};
return /* @__PURE__ */ jsx(Drawer, { open, onOpenChange: setOpen, children: /* @__PURE__ */ jsxs(Drawer.Content, { children: [
/* @__PURE__ */ jsx(Drawer.Header, { children: /* @__PURE__ */ jsx(Drawer.Title, { className: "font-medium", children: "Review Details" }) }),
/* @__PURE__ */ jsxs(Drawer.Body, { className: "flex flex-col divide-y", children: [
/* @__PURE__ */ jsx(SectionRow, { title: "Customer", value: /* @__PURE__ */ jsx(CustomerValue, {}) }),
/* @__PURE__ */ jsx(SectionRow, { title: "Status", value: /* @__PURE__ */ jsx(StatusValue, {}) }),
/* @__PURE__ */ jsx(SectionRow, { title: "Created At", value: /* @__PURE__ */ jsx(CreatedAtValue, {}) }),
/* @__PURE__ */ jsx(SectionRow, { title: "Product", value: /* @__PURE__ */ jsx(ProductValue, {}) }),
/* @__PURE__ */ jsx(SectionRow, { title: "Order", value: /* @__PURE__ */ jsx(OrderValue, {}) }),
/* @__PURE__ */ jsx(
SectionRow,
{
title: "Rating",
value: /* @__PURE__ */ jsx(ReviewStars, { rating: review.rating })
}
),
/* @__PURE__ */ jsx(SectionRow, { title: "Review", value: /* @__PURE__ */ jsx(ReviewContent, {}) }),
review.images.length > 0 && /* @__PURE__ */ jsx(SectionRow, { title: "Images", value: /* @__PURE__ */ jsx(ImagesValue, {}) }),
/* @__PURE__ */ jsx(
SectionRow,
{
title: "Response",
value: review.response ? /* @__PURE__ */ jsx(ResponseValue, {}) : /* @__PURE__ */ jsx(Text, { children: "No response" })
}
),
!!((_a = review.response) == null ? void 0 : _a.created_at) && /* @__PURE__ */ jsx(
SectionRow,
{
title: "Responded At",
value: /* @__PURE__ */ jsx(Text, { children: DateTime.fromISO(review.response.created_at).toFormat(
"LLL dd yyyy hh:mm a"
) })
}
)
] }),
/* @__PURE__ */ jsx(Drawer.Footer, { children: /* @__PURE__ */ jsx(Drawer.Close, { asChild: true, children: /* @__PURE__ */ jsx(Button, { variant: "secondary", children: "Close" }) }) })
] }) });
};
const PRODUCT_REVIEW_STATUSES = ["approved", "flagged", "pending"];
const columnHelper = createDataTableColumnHelper();
const getColumns = (showColumns, actions) => {
const updateStatusActions = (review) => {
return PRODUCT_REVIEW_STATUSES.filter((status) => status !== review.status).map((status) => ({
icon: /* @__PURE__ */ jsx(CheckCircle, { className: "h-4 w-4" }),
label: `Mark as ${status}`,
onClick: () => actions.updateStatus({ reviewId: review.id, status })
}));
};
const allColumns = [
columnHelper.accessor("product", {
id: "product",
header: "Product",
enableSorting: false,
cell: ({ row }) => {
const product = row.original.product;
return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4 py-2 w-full min-w-[200px] max-w-[300px]", children: [
product.thumbnail ? /* @__PURE__ */ jsx("img", { className: "h-8 w-8 flex-shrink-0 rounded-md my-1", src: product.thumbnail, alt: product.title }) : /* @__PURE__ */ jsx("div", { className: "h-8 w-8 flex-shrink-0 rounded-md bg-gray-200" }),
/* @__PURE__ */ jsx("div", { className: "flex-1 min-w-0", children: /* @__PURE__ */ jsx(Link, { to: `/products/${product.id}`, children: /* @__PURE__ */ jsx("span", { className: "text-sm whitespace-normal break-words hover:underline line-clamp-3", children: product.title }) }) })
] });
}
}),
columnHelper.accessor("order", {
id: "order",
header: "Order",
enableSorting: false,
cell: ({ row }) => {
return /* @__PURE__ */ jsx(Link, { to: `/orders/${row.original.order.id}`, children: /* @__PURE__ */ jsxs("span", { className: "text-sm whitespace-normal break-words hover:underline", children: [
"#",
row.original.order.display_id
] }) });
}
}),
columnHelper.accessor("status", {
id: "status",
header: "Status",
enableSorting: false,
cell: ({ row }) => {
return /* @__PURE__ */ jsx("span", { children: row.original.status });
}
}),
columnHelper.accessor("created_at", {
id: "created_at",
header: "Created At",
enableSorting: false,
cell: ({ row }) => {
return /* @__PURE__ */ jsx("div", { className: "min-w-[160px] max-w-[200px]", children: /* @__PURE__ */ jsx("span", { children: DateTime.fromISO(row.original.created_at).toFormat("LLL dd yyyy hh:mm a") }) });
}
}),
columnHelper.accessor("name", {
id: "name",
header: "Customer",
enableSorting: false,
cell: ({ row }) => {
return /* @__PURE__ */ jsx("div", { className: "min-w-[120px] max-w-[200px]", children: /* @__PURE__ */ jsx("span", { className: "whitespace-normal break-words", children: row.original.name }) });
}
}),
columnHelper.accessor("content", {
id: "content",
header: "Review",
enableSorting: false,
cell: ({ row }) => {
const rating = row.original.rating;
const content = row.original.content;
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2 my-2 min-w-[200px] max-w-[400px]", children: [
/* @__PURE__ */ jsx(ReviewStars, { rating }),
/* @__PURE__ */ jsx("p", { className: "text-gray-700 whitespace-normal break-words line-clamp-3", children: content })
] });
}
}),
columnHelper.accessor("images", {
id: "images",
header: "Images",
enableSorting: false,
cell: ({ row }) => {
return /* @__PURE__ */ jsx("div", { className: "flex gap-2", children: row.original.images.length });
}
}),
columnHelper.accessor("response", {
id: "response",
header: "Response",
enableSorting: false,
cell: ({ row }) => {
var _a;
const content = (_a = row.original.response) == null ? void 0 : _a.content;
if (!content) {
return /* @__PURE__ */ jsx("div", { className: "min-w-[160px] max-w-[300px]", children: /* @__PURE__ */ jsx("span", { className: "text-gray-400", children: "No response" }) });
}
return /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-2 my-2 min-w-[160px] max-w-[300px]", children: /* @__PURE__ */ jsx("p", { className: "text-gray-700 whitespace-normal break-words line-clamp-3", children: content }) });
}
}),
columnHelper.action({
// @ts-ignore
id: "actions",
actions: ({ row }) => [
{
icon: /* @__PURE__ */ jsx(Eye, { className: "h-4 w-4" }),
label: "View details",
onClick: () => {
actions.setSelectedReviewForDetails(row.original);
}
},
{
icon: /* @__PURE__ */ jsx(ChatBubble, { className: "h-4 w-4" }),
label: row.original.response ? "Edit response" : "Add response",
onClick: () => {
actions.setSelectedReview(row.original);
}
},
...updateStatusActions(row.original)
]
})
];
if (!showColumns) return allColumns;
return showColumns.map((c) => allColumns.find((column) => column.id === c)).filter((c) => !!c);
};
const ProductReviewDataTable = ({
defaultQuery,
showColumns
}) => {
const defaultLimit = defaultQuery.limit ?? 5;
const defaultOffset = defaultQuery.offset ?? 0;
const [selectedReview, setSelectedReview] = useState(null);
const [selectedReviewForDetails, setSelectedReviewForDetails] = useState(null);
const [query, setQuery] = useState({ ...defaultQuery, limit: defaultLimit, offset: defaultOffset });
const { mutate: updateStatus } = useAdminUpdateProductReviewStatusMutation();
const { data, isLoading } = useAdminListProductReviews({ ...query, limit: defaultLimit });
const table = useDataTable({
columns: getColumns(showColumns, { setSelectedReview, setSelectedReviewForDetails, updateStatus }),
data: (data == null ? void 0 : data.product_reviews) ?? [],
getRowId: (review) => review.id,
rowCount: (data == null ? void 0 : data.count) ?? 0,
isLoading,
search: {
state: query.q ?? "",
onSearchChange: (q) => {
setQuery({ ...query, q });
}
},
pagination: {
state: {
pageIndex: Math.floor(query.offset / query.limit),
pageSize: query.limit
},
onPaginationChange: (pagination) => {
const newQuery = { ...query, offset: pagination.pageIndex * pagination.pageSize, limit: pagination.pageSize };
setQuery(newQuery);
}
}
});
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsxs(DataTable, { instance: table, children: [
/* @__PURE__ */ jsxs(DataTable.Toolbar, { className: "flex flex-col items-start justify-between gap-2 md:flex-row md:items-center", children: [
/* @__PURE__ */ jsx(Heading, { children: "Product Reviews" }),
/* @__PURE__ */ jsx(DataTable.Search, {})
] }),
/* @__PURE__ */ jsx(DataTable.Table, {}),
/* @__PURE__ */ jsx(DataTable.Pagination, {})
] }),
selectedReview && /* @__PURE__ */ jsx(
ProductReviewResponseDrawer,
{
review: selectedReview,
open: selectedReview !== null,
setOpen: (open) => setSelectedReview(open ? selectedReview : null)
}
),
selectedReviewForDetails && /* @__PURE__ */ jsx(
ProductReviewDetailsDrawer,
{
review: selectedReviewForDetails,
open: selectedReviewForDetails !== null,
setOpen: (open) => setSelectedReviewForDetails(open ? selectedReviewForDetails : null)
}
)
] });
};
const OrderDetailsProductReviewsWidget$1 = ({ data: order }) => {
return /* @__PURE__ */ jsx(Container, { className: "mb-2", children: /* @__PURE__ */ jsx(ProductReviewDataTable, { defaultQuery: { order_id: order.id } }) });
};
defineWidgetConfig({
zone: "order.details.after"
});
const OrderDetailsProductReviewsWidget = ({
data: product
}) => {
return /* @__PURE__ */ jsx(Container, { className: "mb-2", children: /* @__PURE__ */ jsx(ProductReviewDataTable, { defaultQuery: { product_id: product.id } }) });
};
defineWidgetConfig({
zone: "product.details.after"
});
const widgetModule = { widgets: [
{
Component: OrderDetailsProductReviewsWidget$1,
zone: ["order.details.after"]
},
{
Component: OrderDetailsProductReviewsWidget,
zone: ["product.details.after"]
}
] };
const routeModule = {
routes: []
};
const menuItemModule = {
menuItems: []
};
const formModule = { customFields: {} };
const displayModule = {
displays: {}
};
const plugin = {
widgetModule,
routeModule,
menuItemModule,
formModule,
displayModule
};
export {
plugin as default
};