@lodashventure/medusa-review 1.4.14 → 1.4.15

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.
@@ -0,0 +1,494 @@
1
+ "use strict";
2
+ const jsxRuntime = require("react/jsx-runtime");
3
+ const adminSdk = require("@medusajs/admin-sdk");
4
+ const icons = require("@medusajs/icons");
5
+ const ui = require("@medusajs/ui");
6
+ const axios = require("axios");
7
+ const Mentions = require("rc-mentions");
8
+ const react = require("react");
9
+ const reactRouterDom = require("react-router-dom");
10
+ const Medusa = require("@medusajs/js-sdk");
11
+ require("@medusajs/admin-shared");
12
+ const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
13
+ const axios__default = /* @__PURE__ */ _interopDefault(axios);
14
+ const Mentions__default = /* @__PURE__ */ _interopDefault(Mentions);
15
+ const Medusa__default = /* @__PURE__ */ _interopDefault(Medusa);
16
+ const statusColor = (status) => status === "approved" ? "green" : status === "rejected" ? "red" : "grey";
17
+ const mimeTypes = ["image", "video"];
18
+ const MediaViewer = ({ media, className }) => {
19
+ const [open, setOpen] = react.useState(false);
20
+ const mimeType = media.mimeType.split("/")[0];
21
+ if (!mimeTypes.includes(mimeType)) {
22
+ return null;
23
+ }
24
+ return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: /* @__PURE__ */ jsxRuntime.jsxs(ui.FocusModal, { open, onOpenChange: setOpen, children: [
25
+ /* @__PURE__ */ jsxRuntime.jsx(ui.FocusModal.Trigger, { className: "bg-ui-bg-overlay ", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: ui.clx("w-24 h-24", className), children: [
26
+ mimeType === "image" && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "p-0 w-full h-full object-contain flex items-center justify-center", children: /* @__PURE__ */ jsxRuntime.jsx("img", { src: media.fileUrl, alt: "an image" }) }),
27
+ mimeType === "video" && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "p-2 w-full h-full flex items-center justify-center", children: /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: "Watch Video" }) })
28
+ ] }) }),
29
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.FocusModal.Content, { className: "w-[80vw] h-[80vh] mx-auto my-auto", children: [
30
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.FocusModal.Header, { children: [
31
+ /* @__PURE__ */ jsxRuntime.jsx(ui.FocusModal.Title, { children: media.fileId }),
32
+ /* @__PURE__ */ jsxRuntime.jsx(ui.FocusModal.Close, {})
33
+ ] }),
34
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.FocusModal.Body, { className: "flex items-center justify-center", children: [
35
+ mimeType === "image" && /* @__PURE__ */ jsxRuntime.jsx("img", { src: media.fileUrl, alt: "an image" }),
36
+ mimeType === "video" && /* @__PURE__ */ jsxRuntime.jsx("video", { src: media.fileUrl, controls: true })
37
+ ] })
38
+ ] })
39
+ ] }) });
40
+ };
41
+ const statusMap = {
42
+ pending: "in-progress",
43
+ approved: "completed",
44
+ rejected: "completed"
45
+ };
46
+ const ReviewChild = ({ review, onAction }) => {
47
+ var _a, _b, _c;
48
+ const color = statusColor(review.status);
49
+ const [selectedStatus, setSelectedStatus] = react.useState("pending");
50
+ return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: /* @__PURE__ */ jsxRuntime.jsxs(ui.ProgressAccordion.Item, { value: review.id, className: "px-4", children: [
51
+ /* @__PURE__ */ jsxRuntime.jsx(
52
+ ui.ProgressAccordion.Header,
53
+ {
54
+ status: statusMap[review.status],
55
+ className: "pl-0",
56
+ children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2", children: [
57
+ /* @__PURE__ */ jsxRuntime.jsx(ui.StatusBadge, { color, children: review.status.charAt(0).toUpperCase() + review.status.slice(1) }),
58
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "w-64 truncate", children: review.title })
59
+ ] })
60
+ }
61
+ ),
62
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.ProgressAccordion.Content, { className: "pl-16 pb-4", children: [
63
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2", children: [
64
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "font-medium", children: review.rating ? `Rating: ${review.rating}` : null }),
65
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-muted", children: new Date(review.created_at).toLocaleDateString("en-GB") })
66
+ ] }),
67
+ /* @__PURE__ */ jsxRuntime.jsxs(
68
+ reactRouterDom.Link,
69
+ {
70
+ className: "text-ui-fg-muted hover:text-ui-fg-on-color",
71
+ to: review.customer_id ? `/customers/${review.customer_id}` : "",
72
+ children: [
73
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-sm font-medium", children: "User:" }),
74
+ " ",
75
+ review.is_admin ? "Admin" : ((_a = review.customer) == null ? void 0 : _a.first_name) ?? review.customer_id
76
+ ]
77
+ }
78
+ ),
79
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "whitespace-pre-wrap my-2", children: review.content }),
80
+ Boolean((_b = review.medias) == null ? void 0 : _b.length) && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex gap-2 my-2", children: (_c = review.medias) == null ? void 0 : _c.map((media) => /* @__PURE__ */ jsxRuntime.jsx(MediaViewer, { media }, media.id)) }),
81
+ review.status === "pending" && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2 justify-end mt-2", children: [
82
+ /* @__PURE__ */ jsxRuntime.jsx(
83
+ ui.Button,
84
+ {
85
+ variant: "danger",
86
+ disabled: selectedStatus === "rejected",
87
+ onClick: () => {
88
+ setSelectedStatus("rejected");
89
+ onAction == null ? void 0 : onAction(review.id, "reject");
90
+ },
91
+ children: "Reject"
92
+ }
93
+ ),
94
+ /* @__PURE__ */ jsxRuntime.jsx(
95
+ ui.Button,
96
+ {
97
+ variant: "primary",
98
+ disabled: selectedStatus === "approved",
99
+ onClick: () => {
100
+ setSelectedStatus("approved");
101
+ onAction == null ? void 0 : onAction(review.id, "approve");
102
+ },
103
+ children: "Approve"
104
+ }
105
+ )
106
+ ] })
107
+ ] })
108
+ ] }) });
109
+ };
110
+ const columnHelper = ui.createDataTableColumnHelper();
111
+ const reviewColumns = [
112
+ columnHelper.select(),
113
+ columnHelper.accessor("product", {
114
+ header: "Product",
115
+ id: "product",
116
+ cell: ({ row }) => {
117
+ var _a;
118
+ return /* @__PURE__ */ jsxRuntime.jsx(reactRouterDom.Link, { to: `/products/${row.original.product_id}`, children: (_a = row.original.product) == null ? void 0 : _a.title });
119
+ }
120
+ }),
121
+ columnHelper.accessor("children", {
122
+ header: "Pending Replies",
123
+ id: "pending-replies",
124
+ cell: ({ row }) => {
125
+ var _a;
126
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
127
+ ((_a = row.original.children) == null ? void 0 : _a.filter((r) => r.status === "pending").length) || 0,
128
+ " ",
129
+ "Replies"
130
+ ] });
131
+ }
132
+ }),
133
+ columnHelper.accessor("children", {
134
+ header: "Replies",
135
+ id: "replies",
136
+ cell: ({ row }) => {
137
+ var _a;
138
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
139
+ ((_a = row.original.children) == null ? void 0 : _a.length) || 0,
140
+ " Replies"
141
+ ] });
142
+ }
143
+ }),
144
+ columnHelper.accessor("status", {
145
+ header: "Status",
146
+ id: "status",
147
+ cell: ({ row }) => {
148
+ return /* @__PURE__ */ jsxRuntime.jsx(ui.StatusBadge, { color: statusColor(row.original.status), children: row.original.status.charAt(0).toUpperCase() + row.original.status.slice(1) });
149
+ }
150
+ }),
151
+ columnHelper.accessor("rating", {
152
+ header: "Rating",
153
+ id: "rating"
154
+ }),
155
+ columnHelper.accessor("title", {
156
+ header: "Title",
157
+ id: "title",
158
+ maxSize: 150,
159
+ cell: ({ row }) => {
160
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "truncate", children: row.original.title });
161
+ }
162
+ }),
163
+ columnHelper.accessor("content", {
164
+ header: "Content",
165
+ id: "content",
166
+ maxSize: 200,
167
+ cell: ({ row }) => {
168
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "truncate", children: row.original.content });
169
+ }
170
+ })
171
+ ];
172
+ const ReviewTable = ({ title, table }) => {
173
+ return /* @__PURE__ */ jsxRuntime.jsxs(ui.Container, { children: [
174
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.DataTable, { instance: table, children: [
175
+ /* @__PURE__ */ jsxRuntime.jsx(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: title }) }),
176
+ /* @__PURE__ */ jsxRuntime.jsx(ui.DataTable.Table, {}),
177
+ /* @__PURE__ */ jsxRuntime.jsx(ui.DataTable.Pagination, {}),
178
+ /* @__PURE__ */ jsxRuntime.jsx(ui.DataTable.CommandBar, { selectedLabel: (count) => `${count} selected` })
179
+ ] }),
180
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Toaster, {})
181
+ ] });
182
+ };
183
+ const sdk = new Medusa__default.default({
184
+ baseUrl: "http://localhost:9000",
185
+ debug: process.env.NODE_ENV === "development",
186
+ auth: {
187
+ type: "session"
188
+ }
189
+ });
190
+ const commandHelper = ui.createDataTableCommandHelper();
191
+ const useCommands = (refetch) => {
192
+ return [
193
+ commandHelper.command({
194
+ label: "Approve",
195
+ shortcut: "A",
196
+ action: async (selection) => {
197
+ const reviewsToApproveIds = Object.keys(selection);
198
+ sdk.client.fetch("/admin/reviews/status", {
199
+ method: "POST",
200
+ body: {
201
+ ids: reviewsToApproveIds,
202
+ status: "approved"
203
+ }
204
+ }).then(() => {
205
+ ui.toast.success("Reviews approved");
206
+ refetch();
207
+ }).catch(() => {
208
+ ui.toast.error("Failed to approve reviews");
209
+ });
210
+ }
211
+ }),
212
+ commandHelper.command({
213
+ label: "Reject",
214
+ shortcut: "R",
215
+ action: async (selection) => {
216
+ const reviewsToRejectIds = Object.keys(selection);
217
+ sdk.client.fetch("/admin/reviews/status", {
218
+ method: "POST",
219
+ body: {
220
+ ids: reviewsToRejectIds,
221
+ status: "rejected"
222
+ }
223
+ }).then(() => {
224
+ ui.toast.success("Reviews rejected");
225
+ refetch();
226
+ }).catch(() => {
227
+ ui.toast.error("Failed to reject reviews");
228
+ });
229
+ }
230
+ })
231
+ ];
232
+ };
233
+ const limit = 20;
234
+ const ReviewsPage = () => {
235
+ var _a, _b;
236
+ const [pagination, setPagination] = react.useState({
237
+ pageSize: limit,
238
+ pageIndex: 0
239
+ });
240
+ const offset = react.useMemo(() => {
241
+ return pagination.pageIndex * limit;
242
+ }, [pagination]);
243
+ const [rowSelection, setRowSelection] = react.useState(
244
+ {}
245
+ );
246
+ const [isDrawerOpen, setIsDrawerOpen] = react.useState(false);
247
+ const [selectedReview, setSelectedReview] = react.useState(null);
248
+ const [replyContent, setReplyContent] = react.useState("");
249
+ const pendingReplies = react.useMemo(() => {
250
+ var _a2;
251
+ const ids = ((_a2 = selectedReview == null ? void 0 : selectedReview.children) == null ? void 0 : _a2.filter((child) => child.status === "pending")) ?? [];
252
+ if ((selectedReview == null ? void 0 : selectedReview.status) === "pending") {
253
+ ids.push(selectedReview);
254
+ }
255
+ return ids;
256
+ }, [selectedReview]);
257
+ const [reviews, setReviews] = react.useState(null);
258
+ const [isLoading, setIsLoading] = react.useState(true);
259
+ const [error, setError] = react.useState(null);
260
+ const fetchReviews = async () => {
261
+ setIsLoading(true);
262
+ setError(null);
263
+ try {
264
+ const { data } = await axios__default.default.get("/admin/reviews", {
265
+ params: {
266
+ offset: pagination.pageIndex * pagination.pageSize,
267
+ limit: pagination.pageSize,
268
+ order: "-created_at"
269
+ }
270
+ });
271
+ setReviews(data);
272
+ } catch (err) {
273
+ setError(
274
+ err instanceof Error ? err : new Error("Failed to fetch reviews")
275
+ );
276
+ } finally {
277
+ setIsLoading(false);
278
+ }
279
+ };
280
+ react.useEffect(() => {
281
+ fetchReviews();
282
+ }, [offset, limit, pagination.pageIndex, pagination.pageSize]);
283
+ const refetch = fetchReviews;
284
+ const users = react.useMemo(() => {
285
+ var _a2;
286
+ const _users = reviews == null ? void 0 : reviews.reviews.filter((review) => review.product_id === (selectedReview == null ? void 0 : selectedReview.product_id)).map((review) => {
287
+ var _a3;
288
+ const children = review.children || [];
289
+ return [
290
+ {
291
+ id: review.is_admin ? "Admin" : review.customer_id,
292
+ firstName: review.is_admin ? "Admin" : ((_a3 = review.customer) == null ? void 0 : _a3.first_name) ?? "Customer"
293
+ },
294
+ ...children.map((child) => {
295
+ var _a4;
296
+ return {
297
+ id: child.is_admin ? "Admin" : child.customer_id,
298
+ firstName: child.is_admin ? "Admin" : ((_a4 = child.customer) == null ? void 0 : _a4.first_name) ?? "Customer"
299
+ };
300
+ })
301
+ ];
302
+ }).flat();
303
+ const userMap = new Map((_a2 = _users == null ? void 0 : _users.map) == null ? void 0 : _a2.call(_users, (u) => [u.id, u]));
304
+ const users2 = Array.from(userMap.values());
305
+ return users2.filter((u) => u.id);
306
+ }, [reviews, selectedReview]);
307
+ const commands = useCommands(refetch);
308
+ const table = ui.useDataTable({
309
+ commands,
310
+ columns: reviewColumns,
311
+ data: (reviews == null ? void 0 : reviews.reviews) || [],
312
+ rowCount: (reviews == null ? void 0 : reviews.count) || 0,
313
+ isLoading,
314
+ pagination: {
315
+ state: pagination,
316
+ onPaginationChange: setPagination
317
+ },
318
+ getRowId: (row) => row.id,
319
+ rowSelection: {
320
+ state: rowSelection,
321
+ onRowSelectionChange: setRowSelection
322
+ },
323
+ onRowClick: (_, row) => {
324
+ setSelectedReview(row.original);
325
+ setIsDrawerOpen(true);
326
+ }
327
+ });
328
+ const [toBeSaved, setToBeSaved] = react.useState(/* @__PURE__ */ new Map());
329
+ function handleAddToBeSaved(id, action) {
330
+ setToBeSaved((prev) => {
331
+ const newSet = new Map(prev);
332
+ newSet.set(id, action);
333
+ return newSet;
334
+ });
335
+ }
336
+ async function handleSave() {
337
+ const { approved, rejected } = Array.from(toBeSaved.entries()).reduce(
338
+ (acc, [id, action]) => {
339
+ if (action === "approve") {
340
+ acc.approved.push(id);
341
+ } else if (action === "reject") {
342
+ acc.rejected.push(id);
343
+ }
344
+ return acc;
345
+ },
346
+ { approved: [], rejected: [] }
347
+ );
348
+ await Promise.all([
349
+ axios__default.default.post("/admin/reviews/status", {
350
+ ids: approved,
351
+ status: "approved"
352
+ }),
353
+ axios__default.default.post("/admin/reviews/status", {
354
+ ids: rejected,
355
+ status: "rejected"
356
+ })
357
+ ]);
358
+ ui.toast.success("Reviews saved", {
359
+ description: `${approved.length} reviews approved, ${rejected.length} reviews rejected`
360
+ });
361
+ setIsDrawerOpen(false);
362
+ handleResetSelection();
363
+ refetch();
364
+ }
365
+ async function handleReply() {
366
+ await axios__default.default.post("/admin/reviews", {
367
+ product_id: selectedReview == null ? void 0 : selectedReview.product_id,
368
+ parent_id: selectedReview == null ? void 0 : selectedReview.id,
369
+ title: "Reply from Admin",
370
+ content: replyContent
371
+ });
372
+ ui.toast.success("Reply sent");
373
+ refetch();
374
+ setIsDrawerOpen(false);
375
+ handleResetSelection();
376
+ }
377
+ function handleDrawerChange(open) {
378
+ setIsDrawerOpen(open);
379
+ if (!open) {
380
+ handleResetSelection();
381
+ }
382
+ }
383
+ function handleResetSelection() {
384
+ setSelectedReview(null);
385
+ setToBeSaved(/* @__PURE__ */ new Map());
386
+ setReplyContent("");
387
+ }
388
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
389
+ /* @__PURE__ */ jsxRuntime.jsx(ReviewTable, { title: "Reviews", table }),
390
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer, { open: isDrawerOpen, onOpenChange: handleDrawerChange, children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Content, { children: [
391
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Header, { children: [
392
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Title, { children: [
393
+ "Review for ",
394
+ (_a = selectedReview == null ? void 0 : selectedReview.product) == null ? void 0 : _a.title
395
+ ] }),
396
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer.Description, { className: "pt-2", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { onClick: handleSave, disabled: toBeSaved.size <= 0, children: "Approve and Reject replies" }) })
397
+ ] }),
398
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer.Body, { className: "overflow-auto", children: /* @__PURE__ */ jsxRuntime.jsxs(
399
+ ui.ProgressAccordion,
400
+ {
401
+ type: "multiple",
402
+ defaultValue: (pendingReplies == null ? void 0 : pendingReplies.map((child) => child.id)) || [],
403
+ children: [
404
+ selectedReview && /* @__PURE__ */ jsxRuntime.jsx(
405
+ ReviewChild,
406
+ {
407
+ review: selectedReview,
408
+ onAction: (id, action) => {
409
+ handleAddToBeSaved(id, action);
410
+ }
411
+ },
412
+ selectedReview == null ? void 0 : selectedReview.id
413
+ ),
414
+ (_b = selectedReview == null ? void 0 : selectedReview.children) == null ? void 0 : _b.map((child) => /* @__PURE__ */ jsxRuntime.jsx(
415
+ ReviewChild,
416
+ {
417
+ review: child,
418
+ onAction: (id, action) => {
419
+ handleAddToBeSaved(id, action);
420
+ }
421
+ },
422
+ child.id
423
+ ))
424
+ ]
425
+ }
426
+ ) }),
427
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Footer, { className: "flex flex-col gap-2", children: [
428
+ /* @__PURE__ */ jsxRuntime.jsx(
429
+ Mentions__default.default,
430
+ {
431
+ rows: 3,
432
+ options: users.map((user) => ({
433
+ value: user.firstName,
434
+ label: user.firstName
435
+ })),
436
+ value: replyContent,
437
+ onChange: (text) => setReplyContent(text),
438
+ notFoundContent: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "text-ui-bg-subtle", children: "No users found" }),
439
+ className: "w-[98%] text-ui-on-color bg-ui-bg-subtle text-base",
440
+ placement: "top",
441
+ prefix: "@",
442
+ placeholder: "Type @ to mention a user"
443
+ }
444
+ ),
445
+ /* @__PURE__ */ jsxRuntime.jsx(
446
+ ui.Button,
447
+ {
448
+ className: "w-[98%]",
449
+ onClick: handleReply,
450
+ disabled: !replyContent,
451
+ children: "Reply as Admin"
452
+ }
453
+ )
454
+ ] })
455
+ ] }) })
456
+ ] });
457
+ };
458
+ const config = adminSdk.defineRouteConfig({
459
+ label: "Reviews",
460
+ icon: icons.ChatBubbleLeftRight
461
+ });
462
+ const widgetModule = { widgets: [] };
463
+ const routeModule = {
464
+ routes: [
465
+ {
466
+ Component: ReviewsPage,
467
+ path: "/reviews"
468
+ }
469
+ ]
470
+ };
471
+ const menuItemModule = {
472
+ menuItems: [
473
+ {
474
+ label: config.label,
475
+ icon: config.icon,
476
+ path: "/reviews",
477
+ nested: void 0
478
+ }
479
+ ]
480
+ };
481
+ const formModule = { customFields: {} };
482
+ const displayModule = {
483
+ displays: {}
484
+ };
485
+ const i18nModule = { resources: {} };
486
+ const plugin = {
487
+ widgetModule,
488
+ routeModule,
489
+ menuItemModule,
490
+ formModule,
491
+ displayModule,
492
+ i18nModule
493
+ };
494
+ module.exports = plugin;
@@ -0,0 +1,491 @@
1
+ import { jsx, Fragment, jsxs } from "react/jsx-runtime";
2
+ import { defineRouteConfig } from "@medusajs/admin-sdk";
3
+ import { ChatBubbleLeftRight } from "@medusajs/icons";
4
+ import { FocusModal, clx, ProgressAccordion, StatusBadge, Text, Button, createDataTableColumnHelper, Container, DataTable, Heading, Toaster, createDataTableCommandHelper, toast, useDataTable, Drawer } from "@medusajs/ui";
5
+ import axios from "axios";
6
+ import Mentions from "rc-mentions";
7
+ import { useState, useMemo, useEffect } from "react";
8
+ import { Link } from "react-router-dom";
9
+ import Medusa from "@medusajs/js-sdk";
10
+ import "@medusajs/admin-shared";
11
+ const statusColor = (status) => status === "approved" ? "green" : status === "rejected" ? "red" : "grey";
12
+ const mimeTypes = ["image", "video"];
13
+ const MediaViewer = ({ media, className }) => {
14
+ const [open, setOpen] = useState(false);
15
+ const mimeType = media.mimeType.split("/")[0];
16
+ if (!mimeTypes.includes(mimeType)) {
17
+ return null;
18
+ }
19
+ return /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsxs(FocusModal, { open, onOpenChange: setOpen, children: [
20
+ /* @__PURE__ */ jsx(FocusModal.Trigger, { className: "bg-ui-bg-overlay ", children: /* @__PURE__ */ jsxs("div", { className: clx("w-24 h-24", className), children: [
21
+ mimeType === "image" && /* @__PURE__ */ jsx("div", { className: "p-0 w-full h-full object-contain flex items-center justify-center", children: /* @__PURE__ */ jsx("img", { src: media.fileUrl, alt: "an image" }) }),
22
+ mimeType === "video" && /* @__PURE__ */ jsx("div", { className: "p-2 w-full h-full flex items-center justify-center", children: /* @__PURE__ */ jsx(Fragment, { children: "Watch Video" }) })
23
+ ] }) }),
24
+ /* @__PURE__ */ jsxs(FocusModal.Content, { className: "w-[80vw] h-[80vh] mx-auto my-auto", children: [
25
+ /* @__PURE__ */ jsxs(FocusModal.Header, { children: [
26
+ /* @__PURE__ */ jsx(FocusModal.Title, { children: media.fileId }),
27
+ /* @__PURE__ */ jsx(FocusModal.Close, {})
28
+ ] }),
29
+ /* @__PURE__ */ jsxs(FocusModal.Body, { className: "flex items-center justify-center", children: [
30
+ mimeType === "image" && /* @__PURE__ */ jsx("img", { src: media.fileUrl, alt: "an image" }),
31
+ mimeType === "video" && /* @__PURE__ */ jsx("video", { src: media.fileUrl, controls: true })
32
+ ] })
33
+ ] })
34
+ ] }) });
35
+ };
36
+ const statusMap = {
37
+ pending: "in-progress",
38
+ approved: "completed",
39
+ rejected: "completed"
40
+ };
41
+ const ReviewChild = ({ review, onAction }) => {
42
+ var _a, _b, _c;
43
+ const color = statusColor(review.status);
44
+ const [selectedStatus, setSelectedStatus] = useState("pending");
45
+ return /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsxs(ProgressAccordion.Item, { value: review.id, className: "px-4", children: [
46
+ /* @__PURE__ */ jsx(
47
+ ProgressAccordion.Header,
48
+ {
49
+ status: statusMap[review.status],
50
+ className: "pl-0",
51
+ children: /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
52
+ /* @__PURE__ */ jsx(StatusBadge, { color, children: review.status.charAt(0).toUpperCase() + review.status.slice(1) }),
53
+ /* @__PURE__ */ jsx("p", { className: "w-64 truncate", children: review.title })
54
+ ] })
55
+ }
56
+ ),
57
+ /* @__PURE__ */ jsxs(ProgressAccordion.Content, { className: "pl-16 pb-4", children: [
58
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
59
+ /* @__PURE__ */ jsx(Text, { size: "small", className: "font-medium", children: review.rating ? `Rating: ${review.rating}` : null }),
60
+ /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-muted", children: new Date(review.created_at).toLocaleDateString("en-GB") })
61
+ ] }),
62
+ /* @__PURE__ */ jsxs(
63
+ Link,
64
+ {
65
+ className: "text-ui-fg-muted hover:text-ui-fg-on-color",
66
+ to: review.customer_id ? `/customers/${review.customer_id}` : "",
67
+ children: [
68
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-medium", children: "User:" }),
69
+ " ",
70
+ review.is_admin ? "Admin" : ((_a = review.customer) == null ? void 0 : _a.first_name) ?? review.customer_id
71
+ ]
72
+ }
73
+ ),
74
+ /* @__PURE__ */ jsx("div", { className: "whitespace-pre-wrap my-2", children: review.content }),
75
+ Boolean((_b = review.medias) == null ? void 0 : _b.length) && /* @__PURE__ */ jsx("div", { className: "flex gap-2 my-2", children: (_c = review.medias) == null ? void 0 : _c.map((media) => /* @__PURE__ */ jsx(MediaViewer, { media }, media.id)) }),
76
+ review.status === "pending" && /* @__PURE__ */ jsxs("div", { className: "flex gap-2 justify-end mt-2", children: [
77
+ /* @__PURE__ */ jsx(
78
+ Button,
79
+ {
80
+ variant: "danger",
81
+ disabled: selectedStatus === "rejected",
82
+ onClick: () => {
83
+ setSelectedStatus("rejected");
84
+ onAction == null ? void 0 : onAction(review.id, "reject");
85
+ },
86
+ children: "Reject"
87
+ }
88
+ ),
89
+ /* @__PURE__ */ jsx(
90
+ Button,
91
+ {
92
+ variant: "primary",
93
+ disabled: selectedStatus === "approved",
94
+ onClick: () => {
95
+ setSelectedStatus("approved");
96
+ onAction == null ? void 0 : onAction(review.id, "approve");
97
+ },
98
+ children: "Approve"
99
+ }
100
+ )
101
+ ] })
102
+ ] })
103
+ ] }) });
104
+ };
105
+ const columnHelper = createDataTableColumnHelper();
106
+ const reviewColumns = [
107
+ columnHelper.select(),
108
+ columnHelper.accessor("product", {
109
+ header: "Product",
110
+ id: "product",
111
+ cell: ({ row }) => {
112
+ var _a;
113
+ return /* @__PURE__ */ jsx(Link, { to: `/products/${row.original.product_id}`, children: (_a = row.original.product) == null ? void 0 : _a.title });
114
+ }
115
+ }),
116
+ columnHelper.accessor("children", {
117
+ header: "Pending Replies",
118
+ id: "pending-replies",
119
+ cell: ({ row }) => {
120
+ var _a;
121
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
122
+ ((_a = row.original.children) == null ? void 0 : _a.filter((r) => r.status === "pending").length) || 0,
123
+ " ",
124
+ "Replies"
125
+ ] });
126
+ }
127
+ }),
128
+ columnHelper.accessor("children", {
129
+ header: "Replies",
130
+ id: "replies",
131
+ cell: ({ row }) => {
132
+ var _a;
133
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
134
+ ((_a = row.original.children) == null ? void 0 : _a.length) || 0,
135
+ " Replies"
136
+ ] });
137
+ }
138
+ }),
139
+ columnHelper.accessor("status", {
140
+ header: "Status",
141
+ id: "status",
142
+ cell: ({ row }) => {
143
+ return /* @__PURE__ */ jsx(StatusBadge, { color: statusColor(row.original.status), children: row.original.status.charAt(0).toUpperCase() + row.original.status.slice(1) });
144
+ }
145
+ }),
146
+ columnHelper.accessor("rating", {
147
+ header: "Rating",
148
+ id: "rating"
149
+ }),
150
+ columnHelper.accessor("title", {
151
+ header: "Title",
152
+ id: "title",
153
+ maxSize: 150,
154
+ cell: ({ row }) => {
155
+ return /* @__PURE__ */ jsx("div", { className: "truncate", children: row.original.title });
156
+ }
157
+ }),
158
+ columnHelper.accessor("content", {
159
+ header: "Content",
160
+ id: "content",
161
+ maxSize: 200,
162
+ cell: ({ row }) => {
163
+ return /* @__PURE__ */ jsx("div", { className: "truncate", children: row.original.content });
164
+ }
165
+ })
166
+ ];
167
+ const ReviewTable = ({ title, table }) => {
168
+ return /* @__PURE__ */ jsxs(Container, { children: [
169
+ /* @__PURE__ */ jsxs(DataTable, { instance: table, children: [
170
+ /* @__PURE__ */ jsx(DataTable.Toolbar, { className: "flex flex-col items-start justify-between gap-2 md:flex-row md:items-center", children: /* @__PURE__ */ jsx(Heading, { children: title }) }),
171
+ /* @__PURE__ */ jsx(DataTable.Table, {}),
172
+ /* @__PURE__ */ jsx(DataTable.Pagination, {}),
173
+ /* @__PURE__ */ jsx(DataTable.CommandBar, { selectedLabel: (count) => `${count} selected` })
174
+ ] }),
175
+ /* @__PURE__ */ jsx(Toaster, {})
176
+ ] });
177
+ };
178
+ const sdk = new Medusa({
179
+ baseUrl: "http://localhost:9000",
180
+ debug: process.env.NODE_ENV === "development",
181
+ auth: {
182
+ type: "session"
183
+ }
184
+ });
185
+ const commandHelper = createDataTableCommandHelper();
186
+ const useCommands = (refetch) => {
187
+ return [
188
+ commandHelper.command({
189
+ label: "Approve",
190
+ shortcut: "A",
191
+ action: async (selection) => {
192
+ const reviewsToApproveIds = Object.keys(selection);
193
+ sdk.client.fetch("/admin/reviews/status", {
194
+ method: "POST",
195
+ body: {
196
+ ids: reviewsToApproveIds,
197
+ status: "approved"
198
+ }
199
+ }).then(() => {
200
+ toast.success("Reviews approved");
201
+ refetch();
202
+ }).catch(() => {
203
+ toast.error("Failed to approve reviews");
204
+ });
205
+ }
206
+ }),
207
+ commandHelper.command({
208
+ label: "Reject",
209
+ shortcut: "R",
210
+ action: async (selection) => {
211
+ const reviewsToRejectIds = Object.keys(selection);
212
+ sdk.client.fetch("/admin/reviews/status", {
213
+ method: "POST",
214
+ body: {
215
+ ids: reviewsToRejectIds,
216
+ status: "rejected"
217
+ }
218
+ }).then(() => {
219
+ toast.success("Reviews rejected");
220
+ refetch();
221
+ }).catch(() => {
222
+ toast.error("Failed to reject reviews");
223
+ });
224
+ }
225
+ })
226
+ ];
227
+ };
228
+ const limit = 20;
229
+ const ReviewsPage = () => {
230
+ var _a, _b;
231
+ const [pagination, setPagination] = useState({
232
+ pageSize: limit,
233
+ pageIndex: 0
234
+ });
235
+ const offset = useMemo(() => {
236
+ return pagination.pageIndex * limit;
237
+ }, [pagination]);
238
+ const [rowSelection, setRowSelection] = useState(
239
+ {}
240
+ );
241
+ const [isDrawerOpen, setIsDrawerOpen] = useState(false);
242
+ const [selectedReview, setSelectedReview] = useState(null);
243
+ const [replyContent, setReplyContent] = useState("");
244
+ const pendingReplies = useMemo(() => {
245
+ var _a2;
246
+ const ids = ((_a2 = selectedReview == null ? void 0 : selectedReview.children) == null ? void 0 : _a2.filter((child) => child.status === "pending")) ?? [];
247
+ if ((selectedReview == null ? void 0 : selectedReview.status) === "pending") {
248
+ ids.push(selectedReview);
249
+ }
250
+ return ids;
251
+ }, [selectedReview]);
252
+ const [reviews, setReviews] = useState(null);
253
+ const [isLoading, setIsLoading] = useState(true);
254
+ const [error, setError] = useState(null);
255
+ const fetchReviews = async () => {
256
+ setIsLoading(true);
257
+ setError(null);
258
+ try {
259
+ const { data } = await axios.get("/admin/reviews", {
260
+ params: {
261
+ offset: pagination.pageIndex * pagination.pageSize,
262
+ limit: pagination.pageSize,
263
+ order: "-created_at"
264
+ }
265
+ });
266
+ setReviews(data);
267
+ } catch (err) {
268
+ setError(
269
+ err instanceof Error ? err : new Error("Failed to fetch reviews")
270
+ );
271
+ } finally {
272
+ setIsLoading(false);
273
+ }
274
+ };
275
+ useEffect(() => {
276
+ fetchReviews();
277
+ }, [offset, limit, pagination.pageIndex, pagination.pageSize]);
278
+ const refetch = fetchReviews;
279
+ const users = useMemo(() => {
280
+ var _a2;
281
+ const _users = reviews == null ? void 0 : reviews.reviews.filter((review) => review.product_id === (selectedReview == null ? void 0 : selectedReview.product_id)).map((review) => {
282
+ var _a3;
283
+ const children = review.children || [];
284
+ return [
285
+ {
286
+ id: review.is_admin ? "Admin" : review.customer_id,
287
+ firstName: review.is_admin ? "Admin" : ((_a3 = review.customer) == null ? void 0 : _a3.first_name) ?? "Customer"
288
+ },
289
+ ...children.map((child) => {
290
+ var _a4;
291
+ return {
292
+ id: child.is_admin ? "Admin" : child.customer_id,
293
+ firstName: child.is_admin ? "Admin" : ((_a4 = child.customer) == null ? void 0 : _a4.first_name) ?? "Customer"
294
+ };
295
+ })
296
+ ];
297
+ }).flat();
298
+ const userMap = new Map((_a2 = _users == null ? void 0 : _users.map) == null ? void 0 : _a2.call(_users, (u) => [u.id, u]));
299
+ const users2 = Array.from(userMap.values());
300
+ return users2.filter((u) => u.id);
301
+ }, [reviews, selectedReview]);
302
+ const commands = useCommands(refetch);
303
+ const table = useDataTable({
304
+ commands,
305
+ columns: reviewColumns,
306
+ data: (reviews == null ? void 0 : reviews.reviews) || [],
307
+ rowCount: (reviews == null ? void 0 : reviews.count) || 0,
308
+ isLoading,
309
+ pagination: {
310
+ state: pagination,
311
+ onPaginationChange: setPagination
312
+ },
313
+ getRowId: (row) => row.id,
314
+ rowSelection: {
315
+ state: rowSelection,
316
+ onRowSelectionChange: setRowSelection
317
+ },
318
+ onRowClick: (_, row) => {
319
+ setSelectedReview(row.original);
320
+ setIsDrawerOpen(true);
321
+ }
322
+ });
323
+ const [toBeSaved, setToBeSaved] = useState(/* @__PURE__ */ new Map());
324
+ function handleAddToBeSaved(id, action) {
325
+ setToBeSaved((prev) => {
326
+ const newSet = new Map(prev);
327
+ newSet.set(id, action);
328
+ return newSet;
329
+ });
330
+ }
331
+ async function handleSave() {
332
+ const { approved, rejected } = Array.from(toBeSaved.entries()).reduce(
333
+ (acc, [id, action]) => {
334
+ if (action === "approve") {
335
+ acc.approved.push(id);
336
+ } else if (action === "reject") {
337
+ acc.rejected.push(id);
338
+ }
339
+ return acc;
340
+ },
341
+ { approved: [], rejected: [] }
342
+ );
343
+ await Promise.all([
344
+ axios.post("/admin/reviews/status", {
345
+ ids: approved,
346
+ status: "approved"
347
+ }),
348
+ axios.post("/admin/reviews/status", {
349
+ ids: rejected,
350
+ status: "rejected"
351
+ })
352
+ ]);
353
+ toast.success("Reviews saved", {
354
+ description: `${approved.length} reviews approved, ${rejected.length} reviews rejected`
355
+ });
356
+ setIsDrawerOpen(false);
357
+ handleResetSelection();
358
+ refetch();
359
+ }
360
+ async function handleReply() {
361
+ await axios.post("/admin/reviews", {
362
+ product_id: selectedReview == null ? void 0 : selectedReview.product_id,
363
+ parent_id: selectedReview == null ? void 0 : selectedReview.id,
364
+ title: "Reply from Admin",
365
+ content: replyContent
366
+ });
367
+ toast.success("Reply sent");
368
+ refetch();
369
+ setIsDrawerOpen(false);
370
+ handleResetSelection();
371
+ }
372
+ function handleDrawerChange(open) {
373
+ setIsDrawerOpen(open);
374
+ if (!open) {
375
+ handleResetSelection();
376
+ }
377
+ }
378
+ function handleResetSelection() {
379
+ setSelectedReview(null);
380
+ setToBeSaved(/* @__PURE__ */ new Map());
381
+ setReplyContent("");
382
+ }
383
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
384
+ /* @__PURE__ */ jsx(ReviewTable, { title: "Reviews", table }),
385
+ /* @__PURE__ */ jsx(Drawer, { open: isDrawerOpen, onOpenChange: handleDrawerChange, children: /* @__PURE__ */ jsxs(Drawer.Content, { children: [
386
+ /* @__PURE__ */ jsxs(Drawer.Header, { children: [
387
+ /* @__PURE__ */ jsxs(Drawer.Title, { children: [
388
+ "Review for ",
389
+ (_a = selectedReview == null ? void 0 : selectedReview.product) == null ? void 0 : _a.title
390
+ ] }),
391
+ /* @__PURE__ */ jsx(Drawer.Description, { className: "pt-2", children: /* @__PURE__ */ jsx(Button, { onClick: handleSave, disabled: toBeSaved.size <= 0, children: "Approve and Reject replies" }) })
392
+ ] }),
393
+ /* @__PURE__ */ jsx(Drawer.Body, { className: "overflow-auto", children: /* @__PURE__ */ jsxs(
394
+ ProgressAccordion,
395
+ {
396
+ type: "multiple",
397
+ defaultValue: (pendingReplies == null ? void 0 : pendingReplies.map((child) => child.id)) || [],
398
+ children: [
399
+ selectedReview && /* @__PURE__ */ jsx(
400
+ ReviewChild,
401
+ {
402
+ review: selectedReview,
403
+ onAction: (id, action) => {
404
+ handleAddToBeSaved(id, action);
405
+ }
406
+ },
407
+ selectedReview == null ? void 0 : selectedReview.id
408
+ ),
409
+ (_b = selectedReview == null ? void 0 : selectedReview.children) == null ? void 0 : _b.map((child) => /* @__PURE__ */ jsx(
410
+ ReviewChild,
411
+ {
412
+ review: child,
413
+ onAction: (id, action) => {
414
+ handleAddToBeSaved(id, action);
415
+ }
416
+ },
417
+ child.id
418
+ ))
419
+ ]
420
+ }
421
+ ) }),
422
+ /* @__PURE__ */ jsxs(Drawer.Footer, { className: "flex flex-col gap-2", children: [
423
+ /* @__PURE__ */ jsx(
424
+ Mentions,
425
+ {
426
+ rows: 3,
427
+ options: users.map((user) => ({
428
+ value: user.firstName,
429
+ label: user.firstName
430
+ })),
431
+ value: replyContent,
432
+ onChange: (text) => setReplyContent(text),
433
+ notFoundContent: /* @__PURE__ */ jsx("div", { className: "text-ui-bg-subtle", children: "No users found" }),
434
+ className: "w-[98%] text-ui-on-color bg-ui-bg-subtle text-base",
435
+ placement: "top",
436
+ prefix: "@",
437
+ placeholder: "Type @ to mention a user"
438
+ }
439
+ ),
440
+ /* @__PURE__ */ jsx(
441
+ Button,
442
+ {
443
+ className: "w-[98%]",
444
+ onClick: handleReply,
445
+ disabled: !replyContent,
446
+ children: "Reply as Admin"
447
+ }
448
+ )
449
+ ] })
450
+ ] }) })
451
+ ] });
452
+ };
453
+ const config = defineRouteConfig({
454
+ label: "Reviews",
455
+ icon: ChatBubbleLeftRight
456
+ });
457
+ const widgetModule = { widgets: [] };
458
+ const routeModule = {
459
+ routes: [
460
+ {
461
+ Component: ReviewsPage,
462
+ path: "/reviews"
463
+ }
464
+ ]
465
+ };
466
+ const menuItemModule = {
467
+ menuItems: [
468
+ {
469
+ label: config.label,
470
+ icon: config.icon,
471
+ path: "/reviews",
472
+ nested: void 0
473
+ }
474
+ ]
475
+ };
476
+ const formModule = { customFields: {} };
477
+ const displayModule = {
478
+ displays: {}
479
+ };
480
+ const i18nModule = { resources: {} };
481
+ const plugin = {
482
+ widgetModule,
483
+ routeModule,
484
+ menuItemModule,
485
+ formModule,
486
+ displayModule,
487
+ i18nModule
488
+ };
489
+ export {
490
+ plugin as default
491
+ };
@@ -0,0 +1,75 @@
1
+ .rc-mentions {
2
+ display: inline-block;
3
+ position: relative;
4
+ white-space: pre-wrap;
5
+ }
6
+ .rc-mentions > textarea,
7
+ .rc-mentions-measure {
8
+ font-size: inherit;
9
+ font-size-adjust: inherit;
10
+ font-style: inherit;
11
+ font-variant: inherit;
12
+ font-stretch: inherit;
13
+ font-weight: inherit;
14
+ font-family: inherit;
15
+ padding: 0;
16
+ margin: 0;
17
+ line-height: inherit;
18
+ vertical-align: top;
19
+ overflow: inherit;
20
+ word-break: inherit;
21
+ white-space: inherit;
22
+ word-wrap: break-word;
23
+ overflow-x: initial;
24
+ overflow-y: auto;
25
+ text-align: inherit;
26
+ letter-spacing: inherit;
27
+ tab-size: inherit;
28
+ direction: inherit;
29
+ }
30
+ .rc-mentions > textarea {
31
+ border: none;
32
+ width: 100%;
33
+ }
34
+ .rc-mentions-measure {
35
+ position: absolute;
36
+ left: 0;
37
+ right: 0;
38
+ top: 0;
39
+ bottom: 0;
40
+ pointer-events: none;
41
+ color: transparent;
42
+ z-index: -1;
43
+ }
44
+ .rc-mentions-dropdown {
45
+ position: absolute;
46
+ color: #000;
47
+ }
48
+ .rc-mentions-dropdown-menu {
49
+ list-style: none;
50
+ margin: 0;
51
+ padding: 0;
52
+ }
53
+ .rc-mentions-dropdown-menu-item {
54
+ cursor: pointer;
55
+ }
56
+ .rc-mentions {
57
+ font-size: 20px;
58
+ border: 1px solid #999;
59
+ border-radius: 3px;
60
+ overflow: hidden;
61
+ }
62
+ .rc-mentions-dropdown {
63
+ border: 1px solid #999;
64
+ border-radius: 3px;
65
+ background: #fff;
66
+ }
67
+ .rc-mentions-dropdown-menu-item {
68
+ padding: 4px 8px;
69
+ }
70
+ .rc-mentions-dropdown-menu-item-active {
71
+ background: #e6f7ff;
72
+ }
73
+ .rc-mentions-dropdown-menu-item-disabled {
74
+ opacity: 0.5;
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lodashventure/medusa-review",
3
- "version": "1.4.14",
3
+ "version": "1.4.15",
4
4
  "description": "A starter for Medusa plugins.",
5
5
  "author": "Medusa (https://medusajs.com)",
6
6
  "license": "MIT",
@@ -25,7 +25,8 @@
25
25
  ],
26
26
  "scripts": {
27
27
  "build": "medusa plugin:build",
28
- "dev": "medusa plugin:develop"
28
+ "dev": "medusa plugin:develop",
29
+ "prepublishOnly": "medusa plugin:build"
29
30
  },
30
31
  "devDependencies": {
31
32
  "@medusajs/admin-sdk": "2.11.2",
@@ -64,7 +65,8 @@
64
65
  "node": ">=20"
65
66
  },
66
67
  "dependencies": {
68
+ "axios": "^1.7.9",
67
69
  "multer": "^1.4.5-lts.2",
68
70
  "rc-mentions": "^2.20.0"
69
71
  }
70
- }
72
+ }