diff --git a/public/images/vertex-ai.jpeg b/public/images/vertex-ai.jpeg deleted file mode 100644 index 96d9078..0000000 Binary files a/public/images/vertex-ai.jpeg and /dev/null differ diff --git a/public/images/vertex-ai.png b/public/images/vertex-ai.png new file mode 100644 index 0000000..8ada151 Binary files /dev/null and b/public/images/vertex-ai.png differ diff --git a/src/app/api/disapprove-review/route.ts b/src/app/api/disapprove-review/route.ts new file mode 100644 index 0000000..aa45754 --- /dev/null +++ b/src/app/api/disapprove-review/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 { disapproveReview } 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 disapproveReview({ + ...reqBody, + accessToken, + storeHash: authorized.storeHash, + }); + + return NextResponse.json(review); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2a84c56..4a5a7a0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,8 @@ import { Source_Sans_3 } from 'next/font/google'; import { type Metadata } from 'next/types'; -import StyledComponentsRegistry from '~/lib/registry'; +import { TailwindIndicator } from '~/components/TailwindIndicator'; import ThemeProvider from '~/components/ThemeProvider'; +import StyledComponentsRegistry from '~/lib/registry'; import '~/styles/main.css'; const sourceSans = Source_Sans_3({ @@ -9,7 +10,7 @@ const sourceSans = Source_Sans_3({ weight: ['300', '400', '600', '700', '800'], }); -export const metadata: Metadata = { title: 'Product description generator' }; +export const metadata: Metadata = { title: 'Review Pulse - ildecimo BigAI' }; export default function RootLayout({ children, @@ -25,6 +26,7 @@ export default function RootLayout({
{children}
+
diff --git a/src/app/product/[productId]/page.tsx b/src/app/product/[productId]/page.tsx index 6808d2e..3c01740 100644 --- a/src/app/product/[productId]/page.tsx +++ b/src/app/product/[productId]/page.tsx @@ -7,14 +7,13 @@ import { } from '~/server/bigcommerce-api'; import { ProductReviewList } from '~/components/ProductReviewList'; +import { getReviewAnalysesByProductId } from '~/lib/db'; interface PageProps { params: { productId: string }; } export default async function Page(props: PageProps) { - const { productId } = props.params; - const authorized = authorize(); if (!authorized) { @@ -27,19 +26,24 @@ export default async function Page(props: PageProps) { throw new Error('Access token not found. Try to re-install the app.'); } - const id = Number(productId); - - const product = await fetchProductWithAttributes( - id, - accessToken, - authorized.storeHash - ); + const productId = Number(props.params.productId); - const reviews = await fetchReviews(id, accessToken, authorized.storeHash); + const [product, reviews, reviewAnalyses] = await Promise.all([ + fetchProductWithAttributes(productId, accessToken, authorized.storeHash), + fetchReviews(productId, accessToken, authorized.storeHash), + getReviewAnalysesByProductId({ + productId, + storeHash: authorized.storeHash, + }), + ]); return (
- +
); } diff --git a/src/app/product/[productId]/review/[reviewId]/page.tsx b/src/app/product/[productId]/review/[reviewId]/page.tsx index 4975ec3..329a00c 100644 --- a/src/app/product/[productId]/review/[reviewId]/page.tsx +++ b/src/app/product/[productId]/review/[reviewId]/page.tsx @@ -9,10 +9,7 @@ import { } from '~/server/bigcommerce-api'; import { ReviewDetail } from '~/components/ReviewDetail'; -import { - analyzeIssuesCategory, - analyzeReview, -} from '~/server/google-ai/analyze-review'; +import { analyzeReview } from '~/server/google-ai/analyze-review'; interface PageProps { params: { reviewId: string; productId: string }; @@ -48,22 +45,38 @@ export default async function Page(props: PageProps) { const customerReviews = reviews.filter((r) => r.email === review.email); - const sentimentAnalysis = await analyzeReview({ - rating: review.rating, - text: review.text, - title: review.title, + let sentimentAnalysis = await db.getReviewAnalysis({ + productId, + reviewId, + storeHash: authorized.storeHash, }); - const issuesCategories = await analyzeIssuesCategory({ - rating: review.rating, - text: review.text, - title: review.title, - }); + if (!sentimentAnalysis) { + const freshAnalysis = await analyzeReview({ + rating: review.rating, + text: review.text, + title: review.title, + }); + + if (freshAnalysis && typeof freshAnalysis !== 'string') { + sentimentAnalysis = freshAnalysis; + + await db.setReviewAnalysis({ + analysis: freshAnalysis, + productId, + reviewId, + storeHash: authorized.storeHash, + }); + } + } return ( { return ( -
+
{!hideAvatar && (
- AI Avatar -

Vertex AI

+ +

Vertex AI

)}
-
{message}
+
{message}
{!hideAvatar && ( -
+
)}
diff --git a/src/components/IssueBadges.tsx b/src/components/IssueBadges.tsx index 06d9083..50e3c4f 100644 --- a/src/components/IssueBadges.tsx +++ b/src/components/IssueBadges.tsx @@ -1,23 +1,23 @@ import { Badge } from '@bigcommerce/big-design'; interface IssuesBadgesProps { - issuesCategoriesArray: string[]; + issuesCategoriesArray?: string[]; } -export const IssuesBadges = ({ issuesCategoriesArray }: IssuesBadgesProps) => { +export const IssuesBadges = ({ + issuesCategoriesArray = [], +}: IssuesBadgesProps) => { return (
Issues found: - {issuesCategoriesArray.map((issue) => ( - - {issue} - - ))} + {issuesCategoriesArray.length > 0 ? ( + issuesCategoriesArray.map((issue) => ( + + )) + ) : ( + + )}
); }; diff --git a/src/components/ProductReviewList.tsx b/src/components/ProductReviewList.tsx index 1decdd0..902ef1c 100644 --- a/src/components/ProductReviewList.tsx +++ b/src/components/ProductReviewList.tsx @@ -1,4 +1,5 @@ 'use client'; + import { type Product, type Review } from 'types'; import { Table } from '@bigcommerce/big-design'; @@ -11,16 +12,20 @@ import { NextLink } from '~/components/NextLink'; import { ReviewStatusBadge } from '~/components/ReviewStatusBadge'; import { StarRating } from '~/components/StarRating'; +import { ScoreCircle } from '~/components/ScoreCircle'; +import { type ReviewAnalysesByProductIdResponse } from '~/lib/db'; import { convertToDateString } from '~/utils/utils'; interface ProductReviewListProps { product: Product; reviews: Review[]; + reviewAnalyses: ReviewAnalysesByProductIdResponse; } export const ProductReviewList = ({ product, reviews, + reviewAnalyses, }: ProductReviewListProps) => { const averageRating = useMemo(() => { return ( @@ -39,6 +44,15 @@ export const ProductReviewList = ({ ); }, [approvedReviews]); + const averageSentiment = useMemo( + () => + Math.floor( + reviewAnalyses.reduce((acc, analysis) => acc + analysis.data.score, 0) / + reviewAnalyses.length + ), + [reviewAnalyses] + ); + return (
@@ -94,11 +108,38 @@ export const ProductReviewList = ({
} + topRightContent={ + + } />
{ + const score = reviewAnalyses?.find( + (r) => r.id === `${review.id}` + )?.data?.score; + + return ( + + ); + }, + }, { header: 'Rating', hash: 'rating', diff --git a/src/components/ReviewDetail.tsx b/src/components/ReviewDetail.tsx index c72ad8f..020d06a 100644 --- a/src/components/ReviewDetail.tsx +++ b/src/components/ReviewDetail.tsx @@ -1,9 +1,9 @@ 'use client'; -import { Box, Button, Tooltip } from '@bigcommerce/big-design'; +import { Box, Button } from '@bigcommerce/big-design'; import { CheckIcon, EnvelopeIcon, HeartIcon } from '@heroicons/react/24/solid'; import clsx from 'clsx'; -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import GaugeComponent from 'react-gauge-component'; import { type Product, type Review } from 'types'; @@ -16,15 +16,17 @@ import { IssuesBadges } from '~/components/IssueBadges'; import { ReviewStatusBadge } from '~/components/ReviewStatusBadge'; import { StarRating } from '~/components/StarRating'; -import { convertToDateString, convertToUDS } from '~/utils/utils'; +import { CloseIcon } from '@bigcommerce/big-design-icons'; +import { ScoreCircle } from '~/components/ScoreCircle'; +import { type AnalyzeReviewOutputValues } from '~/server/google-ai/analyze-review'; +import { convertToDateString, convertToUDS, parseScore } from '~/utils/utils'; interface ReviewDetailProps { customerOrders: Orders; customerReviews: Review[]; product: Product; review: Review; - sentimentAnalysis: string; - issuesCategories: string | undefined; + sentimentAnalysis?: AnalyzeReviewOutputValues; } export const ReviewDetail = ({ @@ -33,11 +35,13 @@ export const ReviewDetail = ({ product, review: reviewProp, sentimentAnalysis, - issuesCategories, }: 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({ @@ -47,7 +51,23 @@ export const ReviewDetail = ({ }) .then((res) => res.json() as Promise) .then(setReview) - .catch((err) => console.log(err)); + .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( @@ -57,10 +77,8 @@ export const ReviewDetail = ({ ); const formattedTotalSpendings = convertToUDS(totalCustomerSpendings); - const issuesCategoriesArray = useMemo( - () => issuesCategories?.split(',') ?? [], - [issuesCategories] - ); + const sentimentScore = sentimentAnalysis?.score; + const parsedScore = parseScore(sentimentScore); return (
@@ -133,96 +151,138 @@ export const ReviewDetail = ({ } topRightContent={ - - 92 -
- } - > - Overall customer sentiment score - + } /> -
- -

- Sentiment: - = 2 && review.rating < 4, - 'text-green-500': review.rating >= 4, - })} - > - {review.rating < 2 && 'Negative'} - {review.rating >= 2 && review.rating < 4 && 'Neutral'} - {review.rating >= 4 && 'Positive'} - +
+ +

+ Feedback and Suggestions

-
- -
-
- -
-
- + +
+
+ +
} hideAvatar />
+ +
+ {review.status !== 'approved' && + (parsedScore.isNeutral || parsedScore.isPositive) && ( + + )} + + {review.status !== 'disapproved' && parsedScore.isNegative && ( + + )} + + {parsedScore.isPositive && ( + + )} + + {(parsedScore.isNeutral || parsedScore.isNegative) && ( + + )} +
+
+ -
-

- Suggested Actions -

+ +
+

+ Sentiment{' '} + + {parsedScore.string.toLowerCase()} + +

-
- +
+ +
+
- +
+

+ Keywords +

- -
+ {keyword} +
+ ))}
diff --git a/src/components/ScoreCircle.tsx b/src/components/ScoreCircle.tsx new file mode 100644 index 0000000..51d683c --- /dev/null +++ b/src/components/ScoreCircle.tsx @@ -0,0 +1,60 @@ +import { Tooltip } from '@bigcommerce/big-design'; +import clsx from 'clsx'; +import { forwardRef, type ComponentProps } from 'react'; +import { parseScore } from '~/utils/utils'; + +interface ScoreCircleBaseProps { + className?: string; + score?: number; +} + +const ScoreCircleBase = forwardRef( + ({ className, score: scoreProp, ...props }, ref) => { + const score = parseScore(scoreProp); + + return ( +
+ {score.isNull ? '?' : scoreProp} +
+ ); + } +); + +ScoreCircleBase.displayName = 'ScoreCircleBase'; + +interface ScoreCircleProps extends Omit { + tooltip?: React.ReactNode; + tooltipPlacement?: ComponentProps['placement']; +} + +export const ScoreCircle = ({ + score, + tooltip, + tooltipPlacement = 'bottom', +}: ScoreCircleProps) => { + if (tooltip) { + return ( + } + > + {tooltip} + + ); + } + + return ; +}; diff --git a/src/components/TailwindIndicator.tsx b/src/components/TailwindIndicator.tsx new file mode 100644 index 0000000..802b655 --- /dev/null +++ b/src/components/TailwindIndicator.tsx @@ -0,0 +1,14 @@ +export const TailwindIndicator = () => { + if (process.env.NODE_ENV === 'production') return null; + + return ( +
+
xs
+
sm
+
md
+
lg
+
xl
+
2xl
+
+ ); +}; diff --git a/src/lib/db.ts b/src/lib/db.ts index 0383948..84fd0ab 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,13 +1,20 @@ import { initializeApp } from 'firebase/app'; import { + collection, deleteDoc, doc, getDoc, + getDocs, getFirestore, setDoc, updateDoc, } from 'firebase/firestore'; +import { z } from 'zod'; import { env } from '~/env.mjs'; +import { + AnalyzeReviewOutputValues, + analyzeReviewOutputSchema, +} from '~/server/google-ai/analyze-review'; export interface UserData { email: string; @@ -106,3 +113,102 @@ export async function deleteStore(storeHash: string) { const ref = doc(db, 'store', storeHash); await deleteDoc(ref); } + +export async function getReviewAnalysis({ + productId, + reviewId, + storeHash, +}: { + productId: number; + reviewId: number; + storeHash: string; +}): Promise { + if (!storeHash) return null; + + const ref = doc( + db, + 'reviewAnalysis', + storeHash, + 'products', + `${productId}`, + 'reviews', + `${reviewId}` + ); + + const analysisDoc = await getDoc(ref); + + const parsedAnalysis = analyzeReviewOutputSchema.safeParse( + analysisDoc.data() + ); + + if (!parsedAnalysis.success) { + return null; + } + + return parsedAnalysis.data; +} + +export async function setReviewAnalysis({ + analysis, + productId, + reviewId, + storeHash, +}: { + analysis: AnalyzeReviewOutputValues; + productId: number; + reviewId: number; + storeHash: string; +}) { + if (!storeHash) return null; + + const ref = doc( + db, + 'reviewAnalysis', + storeHash, + 'products', + `${productId}`, + 'reviews', + `${reviewId}` + ); + + await setDoc(ref, analysis); +} + +const reviewAnalysesListSchema = z.array( + z.object({ id: z.string(), data: analyzeReviewOutputSchema }) +); + +export type ReviewAnalysesByProductIdResponse = Zod.infer< + typeof reviewAnalysesListSchema +>; + +export async function getReviewAnalysesByProductId({ + productId, + storeHash, +}: { + productId: number; + storeHash: string; +}): Promise { + if (!storeHash) return null; + + const ref = collection( + db, + 'reviewAnalysis', + storeHash, + 'products', + `${productId}`, + 'reviews' + ); + + const snapshot = await getDocs(ref); + + const parsedAnalyses = reviewAnalysesListSchema.safeParse( + snapshot.docs.map((doc) => ({ id: doc.id, data: doc.data() })) + ); + + if (!parsedAnalyses.success) { + return null; + } + + return parsedAnalyses.data; +} diff --git a/src/server/bigcommerce-api/index.ts b/src/server/bigcommerce-api/index.ts index 01369b7..f45dd77 100644 --- a/src/server/bigcommerce-api/index.ts +++ b/src/server/bigcommerce-api/index.ts @@ -90,6 +90,28 @@ export const approveReview = async ({ return review; }; +export const disapproveReview = async ({ + productId, + reviewId, + accessToken, + storeHash, +}: { + productId: number; + reviewId: number; + accessToken: string; + storeHash: string; +}): Promise => { + const review = await updateProductReview({ + productId, + reviewId, + accessToken, + storeHash, + reviewData: { status: 'disapproved' }, + }); + + return review; +}; + // @todo this wrapper isn't really necessary, we should simplify the api. But not today. export const fetchCustomerOrders = async ({ email, diff --git a/src/server/google-ai/analyze-review.ts b/src/server/google-ai/analyze-review.ts index 1c9e4c0..0524fac 100644 --- a/src/server/google-ai/analyze-review.ts +++ b/src/server/google-ai/analyze-review.ts @@ -6,39 +6,78 @@ import { env } from '~/env.mjs'; const MODEL_NAME = 'models/text-bison-001'; const API_KEY = env.GOOGLE_API_KEY; -const analyzeReviewSchema = z.object({ +const analyzeReviewInputSchema = z.object({ rating: z.number(), title: z.string(), text: z.string(), }); +type AnalyzeReviewInputOptions = z.infer; + +export const analyzeReviewOutputSchema = z.object({ + description: z.string(), + issueCategories: z.array(z.string()), + keywords: z.array(z.string()), + score: z.number(), +}); + +export type AnalyzeReviewOutputValues = Zod.infer< + typeof analyzeReviewOutputSchema +>; + const analyzeIssuesCategorySchema = z.object({ rating: z.number(), title: z.string(), text: z.string(), }); -type AnalyzeReviewOptions = z.infer; type AnalyzeIssuesCategoryOptions = z.infer; -const prepareInput = (options: AnalyzeReviewOptions): string => { +const prepareInput = (options: AnalyzeReviewInputOptions): string => { return ` - Title: ${options.title} - Description: ${options.text} - Rating: ${options.rating} / 5 +"Title": ${options.title} +"Description": ${options.text} +"Rating": ${options.rating} / 5 `; }; -export async function analyzeReview( - options: AnalyzeReviewOptions -): Promise { - const input = prepareInput(options); +export async function analyzeReview(options: AnalyzeReviewInputOptions) { + // @todo: store results in firestore to avoid recalling the API for already-analysed reviews. - // @todo: improve prompt - const prompt = `Act as an e-commerce customer care expert who analyzes product reviews. - Task: Based on provided review, provide a sentence explaining the sentiment of the review, and suggest either to accept the review, write a thank you email, or contact the customer for clarifications. - Review: ${input} - `; + const prompt = `Role: E-commerce customer care expert analyzing product reviews and outputting results in JSON format. + +Task: Infer the customer's feelings from the provided product review and describe them. Avoid excessive quoting from the review. Make your assumptions using 25% of the provided review rating, and the other 75% based on the sentiment of the provided review title and review description. + +Input Format: + +- "Title": The review title. +- "Body": The review body text. +- "Rating": The review rating, out of 5. + +Output Format: + +{ + "description": string, + "issueCategories": Array<"shipping" | "product quality" | "product packaging" | "customer service" | "payment process" | "price" | "return and refund" | "sales and promotions" | "website experience" | "customer expectations">, + "keywords": Array, + "score": number +} + +(Remember, the output must be in valid JSON format) + +Output Format details: + +- "description:" A text description of 40 to 50 words explaining the customer's feelings based on their review. +- "issueCategories": Based on provided review, if there are issues, provide categories of the issue from the specified union type. If there are no issues provide an empty array. +- "keywords": The main words or phrases from the review that most influenced the determined sentiment. +- "score": The sentiment score evaluated from the customer's review. This must be a number from 0 to 100, where 0 - 32 is the negative range, 33 - 65 is the neutral range, and 66 - 100 is the positive range. + +The review to analyze: + +- "Title": ${options.title} +- "Description": ${options.text} +- "Rating": ${options.rating} / 5 +`; try { const client = new TextServiceClient({ @@ -50,8 +89,24 @@ export async function analyzeReview( prompt: { text: prompt }, }); - if (response && response[0] && response[0].candidates) { - return response[0].candidates[0]?.output || 'No response from Google AI'; + if (response?.[0]?.candidates) { + const output = response[0].candidates[0]?.output; + + if (env.NODE_ENV === 'development') { + console.log('*** [Vertex Output] ::', output); + } + + if (output) { + const parsedOutput = analyzeReviewOutputSchema.safeParse( + JSON.parse(output) + ); + + if (!parsedOutput.success) { + return 'Error parsing output'; + } + + return parsedOutput.data; + } } } catch (error) { console.error(error); diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 1e9d64f..5ec674c 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,4 +1,5 @@ import { type NewProduct, type Product } from 'types'; +import { Sentiment } from 'types/sentiment'; import { STYLE_OPTIONS } from '~/constants'; import { type PromptAttributes } from '~/context/PromptAttributesContext'; interface KeyToLabelMap { @@ -71,7 +72,7 @@ export const convertToDateString = (date: string) => { day: 'numeric', hour: 'numeric', }); -} +}; export const convertToUDS = (price: number) => { const USDollar = new Intl.NumberFormat('en-US', { @@ -80,4 +81,24 @@ export const convertToUDS = (price: number) => { }); return USDollar.format(price); -}; \ No newline at end of file +}; + +const getSentimentString = (score: number) => { + if (score < 33) return Sentiment.NEGATIVE; + + if (score >= 33 && score < 66) return Sentiment.NEUTRAL; + + return Sentiment.POSITIVE; +}; + +export const parseScore = (score?: number) => { + const sentimentString = + typeof score === 'number' ? getSentimentString(score) : ''; + + const isPositive = sentimentString === Sentiment.POSITIVE; + const isNeutral = sentimentString === Sentiment.NEUTRAL; + const isNegative = sentimentString === Sentiment.NEGATIVE; + const isNull = sentimentString === ''; + + return { isPositive, isNeutral, isNegative, isNull, string: sentimentString }; +}; diff --git a/types/sentiment.ts b/types/sentiment.ts new file mode 100644 index 0000000..dd83f98 --- /dev/null +++ b/types/sentiment.ts @@ -0,0 +1,5 @@ +export enum Sentiment { + NEGATIVE = 'NEGATIVE', + NEUTRAL = 'NEUTRAL', + POSITIVE = 'POSITIVE', +}