UNPKG

@lambdacurry/medusa-product-reviews

Version:
462 lines (461 loc) 21 kB
"use strict"; const jsxRuntime = require("react/jsx-runtime"); const adminSdk = require("@medusajs/admin-sdk"); const ui = require("@medusajs/ui"); const icons = require("@medusajs/icons"); const luxon = require("luxon"); const react = require("react"); const reactQuery = require("@tanstack/react-query"); const medusaPluginsSdk = require("@lambdacurry/medusa-plugins-sdk"); const reactHookForm = require("react-hook-form"); const z = require("zod"); const reactRouterDom = require("react-router-dom"); function _interopNamespace(e) { if (e && e.__esModule) return e; const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } }); if (e) { for (const k in e) { if (k !== "default") { const d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: () => e[k] }); } } } n.default = e; return Object.freeze(n); } const z__namespace = /* @__PURE__ */ _interopNamespace(z); const Container = (props) => { return /* @__PURE__ */ jsxRuntime.jsx(ui.Container, { ...props, className: ui.clx("divide-y p-0", props.className) }); }; const backendUrl = __BACKEND_URL__ ?? "http://localhost:9000"; const sdk = new medusaPluginsSdk.MedusaPluginsSDK({ baseUrl: backendUrl, auth: { type: "session" } }); const QUERY_KEY = ["product-reviews"]; const useAdminListProductReviews = (query) => { return reactQuery.useQuery({ queryKey: [...QUERY_KEY, query], placeholderData: (previousData) => previousData, queryFn: async () => { return sdk.admin.productReviews.list(query); } }); }; const useAdminCreateProductReviewResponseMutation = (reviewId) => { const queryClient = reactQuery.useQueryClient(); return reactQuery.useMutation({ mutationFn: async (body) => { return await sdk.admin.productReviews.createResponse(reviewId, body); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: QUERY_KEY }); } }); }; const useAdminUpdateProductReviewResponseMutation = (reviewId) => { const queryClient = reactQuery.useQueryClient(); return reactQuery.useMutation({ mutationFn: async (body) => { return await sdk.admin.productReviews.updateResponse(reviewId, body); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: QUERY_KEY }); } }); }; const useAdminUpdateProductReviewStatusMutation = () => { const queryClient = reactQuery.useQueryClient(); return reactQuery.useMutation({ mutationFn: async ({ reviewId, status }) => { return await sdk.admin.productReviews.updateStatus(reviewId, status); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: QUERY_KEY }); } }); }; z__namespace.object({ content: z__namespace.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 = reactHookForm.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__ */ jsxRuntime.jsx(ui.Drawer, { open, onOpenChange: setOpen, children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Content, { children: [ /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer.Header, { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer.Title, { className: "font-medium", children: title }) }), /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer.Body, { className: "p-4", children: /* @__PURE__ */ jsxRuntime.jsx("form", { onSubmit: form.handleSubmit(onSubmit), className: "flex flex-col gap-4", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-2", children: [ /* @__PURE__ */ jsxRuntime.jsx(ui.Label, { className: "font-medium", children: "Response" }), /* @__PURE__ */ jsxRuntime.jsx(ui.Textarea, { placeholder: "Write your response...", rows: 4, ...form.register("content") }), form.formState.errors.content && /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { className: "text-red-500", size: "small", children: form.formState.errors.content.message }) ] }) }) }), /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Footer, { children: [ /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer.Close, { asChild: true, children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { variant: "secondary", children: "Cancel" }) }), /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { onClick: form.handleSubmit(onSubmit), children: "Save" }) ] }) ] }) }); }; const ReviewStars = ({ rating }) => { return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex gap-0.5", children: [...Array(5)].map((_, index) => /* @__PURE__ */ jsxRuntime.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__ */ jsxRuntime.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__ */ jsxRuntime.jsxs( "div", { className: ui.clx("text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4", { "grid-cols-[1fr_1fr_28px]": !!actions }), children: [ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", weight: "plus", leading: "compact", children: title }), isValueString ? /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", leading: "compact", className: "whitespace-pre-line text-pretty", children: value ?? "-" }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-wrap gap-1", children: value }), actions && /* @__PURE__ */ jsxRuntime.jsx("div", { children: actions }) ] } ); }; const ProductReviewDetailsDrawer = ({ review, open, setOpen }) => { var _a; if (!review) return null; const ProductValue = () => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-4", children: [ review.product.thumbnail ? /* @__PURE__ */ jsxRuntime.jsx( "img", { className: "h-12 w-12 flex-shrink-0 rounded-md", src: review.product.thumbnail, alt: review.product.title } ) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "h-12 w-12 flex-shrink-0 rounded-md bg-gray-200" }), /* @__PURE__ */ jsxRuntime.jsx(reactRouterDom.Link, { to: `/products/${review.product.id}`, children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { className: "hover:underline", children: review.product.title }) }) ] }); const OrderValue = () => /* @__PURE__ */ jsxRuntime.jsx(reactRouterDom.Link, { to: `/orders/${review.order.id}`, children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Text, { className: "hover:underline", children: [ "#", review.order.display_id ] }) }); const StatusValue = () => /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { className: "text-ui-fg-subtle text-sm", children: review.status }); const CreatedAtValue = () => /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { className: "text-ui-fg-subtle text-sm", children: luxon.DateTime.fromISO(review.created_at).toFormat("LLL dd yyyy hh:mm a") }); const CustomerValue = () => /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-col gap-1", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { children: review.name }) }); const ReviewContent = () => /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-col gap-2", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { className: "whitespace-pre-wrap", children: review.content }) }); const ImagesValue = () => /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-3 gap-2", children: review.images.map((image, index) => /* @__PURE__ */ jsxRuntime.jsx( "img", { src: image.url, alt: `Review image ${index + 1}`, className: "w-full h-full object-cover" } )) }); const ResponseValue = () => { var _a2; return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-col gap-1", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { className: "whitespace-pre-wrap", children: (_a2 = review.response) == null ? void 0 : _a2.content }) }); }; return /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer, { open, onOpenChange: setOpen, children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Content, { children: [ /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer.Header, { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer.Title, { className: "font-medium", children: "Review Details" }) }), /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Body, { className: "flex flex-col divide-y", children: [ /* @__PURE__ */ jsxRuntime.jsx(SectionRow, { title: "Customer", value: /* @__PURE__ */ jsxRuntime.jsx(CustomerValue, {}) }), /* @__PURE__ */ jsxRuntime.jsx(SectionRow, { title: "Status", value: /* @__PURE__ */ jsxRuntime.jsx(StatusValue, {}) }), /* @__PURE__ */ jsxRuntime.jsx(SectionRow, { title: "Created At", value: /* @__PURE__ */ jsxRuntime.jsx(CreatedAtValue, {}) }), /* @__PURE__ */ jsxRuntime.jsx(SectionRow, { title: "Product", value: /* @__PURE__ */ jsxRuntime.jsx(ProductValue, {}) }), /* @__PURE__ */ jsxRuntime.jsx(SectionRow, { title: "Order", value: /* @__PURE__ */ jsxRuntime.jsx(OrderValue, {}) }), /* @__PURE__ */ jsxRuntime.jsx( SectionRow, { title: "Rating", value: /* @__PURE__ */ jsxRuntime.jsx(ReviewStars, { rating: review.rating }) } ), /* @__PURE__ */ jsxRuntime.jsx(SectionRow, { title: "Review", value: /* @__PURE__ */ jsxRuntime.jsx(ReviewContent, {}) }), review.images.length > 0 && /* @__PURE__ */ jsxRuntime.jsx(SectionRow, { title: "Images", value: /* @__PURE__ */ jsxRuntime.jsx(ImagesValue, {}) }), /* @__PURE__ */ jsxRuntime.jsx( SectionRow, { title: "Response", value: review.response ? /* @__PURE__ */ jsxRuntime.jsx(ResponseValue, {}) : /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { children: "No response" }) } ), !!((_a = review.response) == null ? void 0 : _a.created_at) && /* @__PURE__ */ jsxRuntime.jsx( SectionRow, { title: "Responded At", value: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { children: luxon.DateTime.fromISO(review.response.created_at).toFormat( "LLL dd yyyy hh:mm a" ) }) } ) ] }), /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer.Footer, { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer.Close, { asChild: true, children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { variant: "secondary", children: "Close" }) }) }) ] }) }); }; const PRODUCT_REVIEW_STATUSES = ["approved", "flagged", "pending"]; const columnHelper = ui.createDataTableColumnHelper(); const getColumns = (showColumns, actions) => { const updateStatusActions = (review) => { return PRODUCT_REVIEW_STATUSES.filter((status) => status !== review.status).map((status) => ({ icon: /* @__PURE__ */ jsxRuntime.jsx(icons.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__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-4 py-2 w-full min-w-[200px] max-w-[300px]", children: [ product.thumbnail ? /* @__PURE__ */ jsxRuntime.jsx("img", { className: "h-8 w-8 flex-shrink-0 rounded-md my-1", src: product.thumbnail, alt: product.title }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "h-8 w-8 flex-shrink-0 rounded-md bg-gray-200" }), /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex-1 min-w-0", children: /* @__PURE__ */ jsxRuntime.jsx(reactRouterDom.Link, { to: `/products/${product.id}`, children: /* @__PURE__ */ jsxRuntime.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__ */ jsxRuntime.jsx(reactRouterDom.Link, { to: `/orders/${row.original.order.id}`, children: /* @__PURE__ */ jsxRuntime.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__ */ jsxRuntime.jsx("span", { children: row.original.status }); } }), columnHelper.accessor("created_at", { id: "created_at", header: "Created At", enableSorting: false, cell: ({ row }) => { return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-w-[160px] max-w-[200px]", children: /* @__PURE__ */ jsxRuntime.jsx("span", { children: luxon.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__ */ jsxRuntime.jsx("div", { className: "min-w-[120px] max-w-[200px]", children: /* @__PURE__ */ jsxRuntime.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__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-2 my-2 min-w-[200px] max-w-[400px]", children: [ /* @__PURE__ */ jsxRuntime.jsx(ReviewStars, { rating }), /* @__PURE__ */ jsxRuntime.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__ */ jsxRuntime.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__ */ jsxRuntime.jsx("div", { className: "min-w-[160px] max-w-[300px]", children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-gray-400", children: "No response" }) }); } return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-col gap-2 my-2 min-w-[160px] max-w-[300px]", children: /* @__PURE__ */ jsxRuntime.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__ */ jsxRuntime.jsx(icons.Eye, { className: "h-4 w-4" }), label: "View details", onClick: () => { actions.setSelectedReviewForDetails(row.original); } }, { icon: /* @__PURE__ */ jsxRuntime.jsx(icons.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] = react.useState(null); const [selectedReviewForDetails, setSelectedReviewForDetails] = react.useState(null); const [query, setQuery] = react.useState({ ...defaultQuery, limit: defaultLimit, offset: defaultOffset }); const { mutate: updateStatus } = useAdminUpdateProductReviewStatusMutation(); const { data, isLoading } = useAdminListProductReviews({ ...query, limit: defaultLimit }); const table = ui.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__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [ /* @__PURE__ */ jsxRuntime.jsxs(ui.DataTable, { instance: table, children: [ /* @__PURE__ */ jsxRuntime.jsxs(ui.DataTable.Toolbar, { className: "flex flex-col items-start justify-between gap-2 md:flex-row md:items-center", children: [ /* @__PURE__ */ jsxRuntime.jsx(ui.Heading, { children: "Product Reviews" }), /* @__PURE__ */ jsxRuntime.jsx(ui.DataTable.Search, {}) ] }), /* @__PURE__ */ jsxRuntime.jsx(ui.DataTable.Table, {}), /* @__PURE__ */ jsxRuntime.jsx(ui.DataTable.Pagination, {}) ] }), selectedReview && /* @__PURE__ */ jsxRuntime.jsx( ProductReviewResponseDrawer, { review: selectedReview, open: selectedReview !== null, setOpen: (open) => setSelectedReview(open ? selectedReview : null) } ), selectedReviewForDetails && /* @__PURE__ */ jsxRuntime.jsx( ProductReviewDetailsDrawer, { review: selectedReviewForDetails, open: selectedReviewForDetails !== null, setOpen: (open) => setSelectedReviewForDetails(open ? selectedReviewForDetails : null) } ) ] }); }; const OrderDetailsProductReviewsWidget$1 = ({ data: order }) => { return /* @__PURE__ */ jsxRuntime.jsx(Container, { className: "mb-2", children: /* @__PURE__ */ jsxRuntime.jsx(ProductReviewDataTable, { defaultQuery: { order_id: order.id } }) }); }; adminSdk.defineWidgetConfig({ zone: "order.details.after" }); const OrderDetailsProductReviewsWidget = ({ data: product }) => { return /* @__PURE__ */ jsxRuntime.jsx(Container, { className: "mb-2", children: /* @__PURE__ */ jsxRuntime.jsx(ProductReviewDataTable, { defaultQuery: { product_id: product.id } }) }); }; adminSdk.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 i18nModule = { resources: {} }; const plugin = { widgetModule, routeModule, menuItemModule, formModule, displayModule, i18nModule }; module.exports = plugin;