diff --git a/src/app/api/approve-review/route.ts b/src/app/api/review-status/approve/route.ts similarity index 100% rename from src/app/api/approve-review/route.ts rename to src/app/api/review-status/approve/route.ts diff --git a/src/app/api/disapprove-review/route.ts b/src/app/api/review-status/disapprove/route.ts similarity index 100% rename from src/app/api/disapprove-review/route.ts rename to src/app/api/review-status/disapprove/route.ts diff --git a/src/app/api/review-status/pending/route.ts b/src/app/api/review-status/pending/route.ts new file mode 100644 index 0000000..944aec8 --- /dev/null +++ b/src/app/api/review-status/pending/route.ts @@ -0,0 +1,31 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { authorize } from '~/lib/authorize'; +import * as db from '~/lib/db'; +import { setPendingReview } from '~/server/bigcommerce-api'; + +export async function POST(req: NextRequest) { + const authorized = authorize(); + + if (!authorized) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const accessToken = await db.getStoreToken(authorized.storeHash); + + if (!accessToken) { + return new NextResponse( + 'Access token not found. Try to re-install the app.', + { status: 401 } + ); + } + + const reqBody = (await req.json()) as { productId: number; reviewId: number }; + + const review = await setPendingReview({ + ...reqBody, + accessToken, + storeHash: authorized.storeHash, + }); + + return NextResponse.json(review); +} diff --git a/src/components/AIChatBubble.tsx b/src/components/AIChatBubble.tsx index cfdfc98..5bac1d3 100644 --- a/src/components/AIChatBubble.tsx +++ b/src/components/AIChatBubble.tsx @@ -7,7 +7,7 @@ interface AIChatBubbleProps { export const AIChatBubble = ({ message, hideAvatar }: AIChatBubbleProps) => { return ( -
+
{!hideAvatar && (
diff --git a/src/components/OverrideActionsPopover.tsx b/src/components/OverrideActionsPopover.tsx new file mode 100644 index 0000000..141bbb5 --- /dev/null +++ b/src/components/OverrideActionsPopover.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react'; + +import { type Review } from 'types'; + +import { Button, Popover } from '@bigcommerce/big-design'; +import { MoreHorizIcon } from '@bigcommerce/big-design-icons'; + +import { GenerateEmailButton } from '~/components/GenerateEmailButton'; +import { + ApproveReviewButton, + DisapproveReviewButton, + PendingReviewButton, +} from '~/components/ReviewActionButtons'; + +interface OverrideActionsPopoverProps { + review: Review; + productId: number; + setReview: (review: Review) => void; +} + +export const OverrideActionsPopover = ({ + review, + productId, + setReview, +}: OverrideActionsPopoverProps) => { + const [isOpen, setIsOpen] = useState(false); + const [buttonRef, setButtonRef] = useState(null); + + return ( +
+ + setIsOpen(false)} + > +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ); +}; diff --git a/src/components/ProductFilters.tsx b/src/components/ProductFilters.tsx index 03727a2..2143f13 100644 --- a/src/components/ProductFilters.tsx +++ b/src/components/ProductFilters.tsx @@ -1,8 +1,7 @@ -import { Box, Switch } from '@bigcommerce/big-design'; import { useState } from 'react'; import { type SimpleProduct } from 'types'; -import { ProductSearch } from '~/components/ProductSearch'; +import { Box, Form, FormGroup, Input, Switch } from '@bigcommerce/big-design'; interface ProductFiltersProps { setFilteredProducts: (value: SimpleProduct[]) => void; @@ -14,26 +13,53 @@ export const ProductFilters = ({ products, }: ProductFiltersProps) => { const [switchOn, setSwitchOn] = useState(false); + const [searchValue, setSearchValue] = useState(''); + const [subFilteredProducts, setSubFilteredProducts] = useState(products); + + const searchProduct = (search: string, products: SimpleProduct[]) => { + return products.filter((product) => { + return product.name.toLowerCase().includes(search.toLowerCase()); + }); + }; + + const handleSearch = (event: React.ChangeEvent) => { + setSearchValue(event.target.value); + + const productsToFilter = switchOn ? subFilteredProducts : products; + const filteredProducts = searchProduct( + event.target.value, + productsToFilter + ); + + setFilteredProducts(filteredProducts); + }; const handleSwitch = () => { setSwitchOn(!switchOn); - let filteredProducts = products; + const productsToFilter = searchValue ? subFilteredProducts : products; - if (!switchOn) { - filteredProducts = products.filter((product) => { - return product.reviews_count > 0; - }); - } + const filteredProducts = switchOn + ? productsToFilter + : productsToFilter.filter((product) => product.reviews_count > 0); + setSubFilteredProducts(filteredProducts); setFilteredProducts(filteredProducts); }; return ( - +
+ + + +
Show only products with reviews diff --git a/src/components/ProductList.tsx b/src/components/ProductList.tsx index 6a00b36..dc9f717 100644 --- a/src/components/ProductList.tsx +++ b/src/components/ProductList.tsx @@ -5,7 +5,7 @@ import { type SimpleProduct } from 'types'; import { Badge, Table } from '@bigcommerce/big-design'; import { ArrowLongRightIcon } from '@heroicons/react/20/solid'; import Image from 'next/image'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Breadcrumbs } from '~/components/Breadcrumbs'; import { NextLink } from '~/components/NextLink'; @@ -40,6 +40,13 @@ const ReviewsAverage = ({ product }: ReviewsAverageProps) => { const ProductList = ({ products }: ProductListProps) => { const [filteredProducts, setFilteredProducts] = useState(products); + const sortedProductsByReviews = useMemo( + () => + [...filteredProducts].sort((a, b) => { + return b.reviews_count - a.reviews_count; + }), + [filteredProducts] + ); return (
@@ -122,7 +129,7 @@ const ProductList = ({ products }: ProductListProps) => { ), }, ]} - items={filteredProducts} + items={sortedProductsByReviews} />
diff --git a/src/components/ProductSearch.tsx b/src/components/ProductSearch.tsx deleted file mode 100644 index ddf559d..0000000 --- a/src/components/ProductSearch.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Form, FormGroup, Input } from '@bigcommerce/big-design'; -import { useState } from 'react'; - -import { type SimpleProduct } from 'types'; - -interface ProductSearchProps { - setFilteredProducts: (value: SimpleProduct[]) => void; - products: SimpleProduct[]; -} - -export const ProductSearch = ({ - setFilteredProducts, - products, -}: ProductSearchProps) => { - const [value, setValue] = useState(''); - - const handleChange = (event: React.ChangeEvent) => { - setValue(event.target.value); - - const filteredProducts = products.filter((product) => { - return product.name - .toLowerCase() - .includes(event.target.value.toLowerCase()); - }); - - setFilteredProducts(filteredProducts); - }; - - return ( -
- - - -
- ); -}; diff --git a/src/components/ReviewActionButtons.tsx b/src/components/ReviewActionButtons.tsx new file mode 100644 index 0000000..7d2a7f9 --- /dev/null +++ b/src/components/ReviewActionButtons.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react'; + +import { type Review } from 'types'; + +import { Button } from '@bigcommerce/big-design'; +import { + CheckIcon, + CloseIcon, + NotificationsIcon, +} from '@bigcommerce/big-design-icons'; + +interface ReviewActionButtonProps { + reviewId: number; + productId: number; + setReview: (review: Review) => void; +} + +export const ApproveReviewButton = ({ + reviewId, + productId, + setReview, +}: ReviewActionButtonProps) => { + const [isApproving, setIsApproving] = useState(false); + + const onApprove = () => { + setIsApproving(true); + fetch('/api/review-status/approve', { + method: 'POST', + body: JSON.stringify({ + productId, + reviewId, + }), + }) + .then((res) => res.json() as Promise) + .then(setReview) + .catch((err) => console.log(err)) + .finally(() => setIsApproving(false)); + }; + + return ( + + ); +}; + +export const DisapproveReviewButton = ({ + productId, + reviewId, + setReview, +}: ReviewActionButtonProps) => { + const [isDisapproving, setIsDisapproving] = useState(false); + + const onDisapprove = () => { + setIsDisapproving(true); + fetch('/api/review-status/disapprove', { + method: 'POST', + body: JSON.stringify({ + productId, + reviewId, + }), + }) + .then((res) => res.json() as Promise) + .then(setReview) + .catch((err) => console.log(err)) + .finally(() => setIsDisapproving(false)); + }; + + return ( + + ); +}; + +export const PendingReviewButton = ({ + reviewId, + productId, + setReview, +}: ReviewActionButtonProps) => { + const [isSettingPending, setIsSettingPending] = useState(false); + + const onPending = () => { + setIsSettingPending(true); + fetch('/api/review-status/pending', { + method: 'POST', + body: JSON.stringify({ + productId, + reviewId, + }), + }) + .then((res) => res.json() as Promise) + .then(setReview) + .catch((err) => console.log(err)) + .finally(() => setIsSettingPending(false)); + }; + + return ( + + ); +}; diff --git a/src/components/ReviewDetail.tsx b/src/components/ReviewDetail.tsx index cfba4f9..ff4c6ab 100644 --- a/src/components/ReviewDetail.tsx +++ b/src/components/ReviewDetail.tsx @@ -17,6 +17,11 @@ import { Card } from '~/components/Card'; import { ComplimentBadges } from '~/components/ComplimentBadges'; import { GenerateEmailButton } from '~/components/GenerateEmailButton'; import { IssueBadges } from '~/components/IssueBadges'; +import { OverrideActionsPopover } from '~/components/OverrideActionsPopover'; +import { + ApproveReviewButton, + DisapproveReviewButton, +} from '~/components/ReviewActionButtons'; import { ReviewStatusBadge } from '~/components/ReviewStatusBadge'; import { ScoreCircle } from '~/components/ScoreCircle'; import { StarRating } from '~/components/StarRating'; @@ -39,38 +44,6 @@ export const ReviewDetail = ({ sentimentAnalysis, }: ReviewDetailProps) => { const [review, setReview] = useState(reviewProp); - const [isApproving, setIsApproving] = useState(false); - const [isDisapproving, setIsDisapproving] = useState(false); - - const onApprove = () => { - setIsApproving(true); - fetch('/api/approve-review', { - method: 'POST', - body: JSON.stringify({ - productId: product.id, - reviewId: review.id, - }), - }) - .then((res) => res.json() as Promise) - .then(setReview) - .catch((err) => console.log(err)) - .finally(() => setIsApproving(false)); - }; - - const onDisapprove = () => { - setIsDisapproving(true); - fetch('/api/disapprove-review', { - method: 'POST', - body: JSON.stringify({ - productId: product.id, - reviewId: review.id, - }), - }) - .then((res) => res.json() as Promise) - .then(setReview) - .catch((err) => console.log(err)) - .finally(() => setIsDisapproving(false)); - }; const totalCustomerSpendings = customerOrders.reduce( (acc, order) => @@ -168,9 +141,18 @@ export const ReviewDetail = ({ padding="small" borderRadius="normal" > -

- Feedback and Suggestions -

+
+

+ Feedback and Suggestions +

+
+ +
+
@@ -202,26 +184,22 @@ export const ReviewDetail = ({ />
-
+
{review.status !== 'approved' && (parsedScore.isNeutral || parsedScore.isPositive) && ( - + )} {review.status !== 'disapproved' && parsedScore.isNegative && ( - + )} {parsedScore.isPositive && ( diff --git a/src/server/bigcommerce-api/index.ts b/src/server/bigcommerce-api/index.ts index f45dd77..cea17e3 100644 --- a/src/server/bigcommerce-api/index.ts +++ b/src/server/bigcommerce-api/index.ts @@ -112,6 +112,28 @@ export const disapproveReview = async ({ return review; }; +export const setPendingReview = async ({ + productId, + reviewId, + accessToken, + storeHash, +}: { + productId: number; + reviewId: number; + accessToken: string; + storeHash: string; +}): Promise => { + const review = await updateProductReview({ + productId, + reviewId, + accessToken, + storeHash, + reviewData: { status: 'pending' }, + }); + + return review; +}; + // @todo this wrapper isn't really necessary, we should simplify the api. But not today. export const fetchCustomerOrders = async ({ email,