diff --git a/src/app/api/generate-email/route.ts b/src/app/api/generate-email/route.ts new file mode 100644 index 0000000..6e31087 --- /dev/null +++ b/src/app/api/generate-email/route.ts @@ -0,0 +1,37 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { type Review } from 'types'; + +import { authorize } from '~/lib/authorize'; +import * as db from '~/lib/db'; + +import { generateAISuggestedEmail } from '~/server/google-ai/generate-email'; + +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 { review: Review; emailType: string }; + + const email = await generateAISuggestedEmail({ + rating: reqBody.review.rating, + title: reqBody.review.title, + text: reqBody.review.text, + customer: reqBody.review.name, + emailType: + reqBody.emailType === 'thank-you' ? 'Thank you email' : 'Follow-up email', + }); + + return NextResponse.json(email); +} diff --git a/src/components/ComplimentBadges.tsx b/src/components/ComplimentBadges.tsx new file mode 100644 index 0000000..57b85ca --- /dev/null +++ b/src/components/ComplimentBadges.tsx @@ -0,0 +1,23 @@ +import { Badge } from '@bigcommerce/big-design'; + +interface ComplimentBadgesProps { + complimentCategoriesArray?: string[]; +} + +export const ComplimentBadges = ({ + complimentCategoriesArray = [], +}: ComplimentBadgesProps) => { + return ( +
+ Compliments: + + {complimentCategoriesArray.length > 0 ? ( + complimentCategoriesArray.map((compliment) => ( + + )) + ) : ( + + )} +
+ ); +}; diff --git a/src/components/GenerateEmailButton.tsx b/src/components/GenerateEmailButton.tsx new file mode 100644 index 0000000..1db6cab --- /dev/null +++ b/src/components/GenerateEmailButton.tsx @@ -0,0 +1,64 @@ +import { Button } from '@bigcommerce/big-design'; +import { EnvelopeIcon, HeartIcon } from '@heroicons/react/24/solid'; +import { useState } from 'react'; + +import { type Review } from 'types'; +import { type AnalyzeEmailOutputOptions } from '~/server/google-ai/generate-email'; + +interface GenerateEmailButtonProps { + variant: 'thank-you' | 'follow-up'; + review: Review; +} + +export const GenerateEmailButton = ({ + variant, + review, +}: GenerateEmailButtonProps) => { + const [isGenerating, setIsGenerating] = useState(false); + const isThankYou = variant === 'thank-you'; + + const handleGenerateEmail = () => { + console.log('Generating email...'); + + setIsGenerating(true); + fetch('/api/generate-email', { + method: 'POST', + body: JSON.stringify({ + review, + emailType: isThankYou ? 'thank-you' : 'follow-up', + }), + }) + .then((res) => res.json() as Promise) + .then((res) => { + const { body, subject } = res; + + if (body && subject) { + window.open( + `mailto:${review.email}?subject=${subject}&body=${body}`, + '_blank' + ); + } else { + console.warn('Email not generated!', res); + } + }) + .catch((err) => console.log(err)) + .finally(() => setIsGenerating(false)); + }; + + return ( + + ); +}; diff --git a/src/components/IssueBadges.tsx b/src/components/IssueBadges.tsx index 50e3c4f..560292f 100644 --- a/src/components/IssueBadges.tsx +++ b/src/components/IssueBadges.tsx @@ -4,12 +4,12 @@ interface IssuesBadgesProps { issuesCategoriesArray?: string[]; } -export const IssuesBadges = ({ +export const IssueBadges = ({ issuesCategoriesArray = [], }: IssuesBadgesProps) => { return (
- Issues found: + Issues: {issuesCategoriesArray.length > 0 ? ( issuesCategoriesArray.map((issue) => ( diff --git a/src/components/ReviewDetail.tsx b/src/components/ReviewDetail.tsx index 020d06a..cfba4f9 100644 --- a/src/components/ReviewDetail.tsx +++ b/src/components/ReviewDetail.tsx @@ -1,24 +1,26 @@ 'use client'; import { Box, Button } from '@bigcommerce/big-design'; -import { CheckIcon, EnvelopeIcon, HeartIcon } from '@heroicons/react/24/solid'; +import { CloseIcon } from '@bigcommerce/big-design-icons'; +import { CheckIcon } from '@heroicons/react/24/solid'; import clsx from 'clsx'; import { useState } from 'react'; import GaugeComponent from 'react-gauge-component'; import { type Product, type Review } from 'types'; import { type Orders } from '~/server/bigcommerce-api/schemas'; +import { type AnalyzeReviewOutputValues } from '~/server/google-ai/analyze-review'; import { AIChatBubble } from '~/components/AIChatBubble'; import { Breadcrumbs } from '~/components/Breadcrumbs'; import { Card } from '~/components/Card'; -import { IssuesBadges } from '~/components/IssueBadges'; +import { ComplimentBadges } from '~/components/ComplimentBadges'; +import { GenerateEmailButton } from '~/components/GenerateEmailButton'; +import { IssueBadges } from '~/components/IssueBadges'; import { ReviewStatusBadge } from '~/components/ReviewStatusBadge'; +import { ScoreCircle } from '~/components/ScoreCircle'; import { StarRating } from '~/components/StarRating'; -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 { @@ -177,7 +179,7 @@ export const ReviewDetail = ({
+
+ + } + hideAvatar + /> +
+
{review.status !== 'approved' && (parsedScore.isNeutral || parsedScore.isPositive) && ( @@ -210,21 +225,11 @@ export const ReviewDetail = ({ )} {parsedScore.isPositive && ( - + )} {(parsedScore.isNeutral || parsedScore.isNegative) && ( - + )}
diff --git a/src/server/google-ai/analyze-review.ts b/src/server/google-ai/analyze-review.ts index 0524fac..ad82162 100644 --- a/src/server/google-ai/analyze-review.ts +++ b/src/server/google-ai/analyze-review.ts @@ -17,6 +17,7 @@ type AnalyzeReviewInputOptions = z.infer; export const analyzeReviewOutputSchema = z.object({ description: z.string(), issueCategories: z.array(z.string()), + complimentCategories: z.array(z.string()), keywords: z.array(z.string()), score: z.number(), }); @@ -25,22 +26,6 @@ export type AnalyzeReviewOutputValues = Zod.infer< typeof analyzeReviewOutputSchema >; -const analyzeIssuesCategorySchema = z.object({ - rating: z.number(), - title: z.string(), - text: z.string(), -}); - -type AnalyzeIssuesCategoryOptions = z.infer; - -const prepareInput = (options: AnalyzeReviewInputOptions): string => { - return ` -"Title": ${options.title} -"Description": ${options.text} -"Rating": ${options.rating} / 5 - `; -}; - export async function analyzeReview(options: AnalyzeReviewInputOptions) { // @todo: store results in firestore to avoid recalling the API for already-analysed reviews. @@ -59,6 +44,7 @@ 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">, + "complimentCategories": 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 } @@ -69,6 +55,7 @@ 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. +- "complimentCategories": Based on provided review, if there are compliments, provide categories of the compliment from the specified union type. If there are no compliments 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. @@ -93,7 +80,7 @@ The review to analyze: const output = response[0].candidates[0]?.output; if (env.NODE_ENV === 'development') { - console.log('*** [Vertex Output] ::', output); + console.log('*** [Vertex Review Analysis Output] ::', output); } if (output) { @@ -115,31 +102,4 @@ The review to analyze: return 'No response from Google AI'; } -export async function analyzeIssuesCategory( - options: AnalyzeIssuesCategoryOptions -) { - const input = prepareInput(options); - - const prompt = `Act as an e-commerce customer care expert who analyzes product reviews. - Task: Based on provided review, if there are issues respond with one or multiple categories of the issue from [shipping, product quality, product packaging, customer service, payment process, price, return and refund, sales and promotions, website experience, customer expectations]. - If the review does not contain issues, respond with "no issues". If the review contains multiple issues, respond with all of them separated by commas. - Review: ${input} - `; - try { - const client = new TextServiceClient({ - authClient: new GoogleAuth().fromAPIKey(API_KEY), - }); - - const response = await client.generateText({ - model: MODEL_NAME, - prompt: { text: prompt }, - }); - - if (response && response[0] && response[0].candidates) { - return response[0].candidates[0]?.output || 'No response from Google AI'; - } - } catch (error) { - console.error(error); - } -} diff --git a/src/server/google-ai/generate-email.ts b/src/server/google-ai/generate-email.ts new file mode 100644 index 0000000..acc87b0 --- /dev/null +++ b/src/server/google-ai/generate-email.ts @@ -0,0 +1,121 @@ +import { TextServiceClient } from '@google-ai/generativelanguage'; +import { GoogleAuth } from 'google-auth-library'; +import { z } from 'zod'; +import { env } from '~/env.mjs'; + +const MODEL_NAME = 'models/text-bison-001'; +const API_KEY = env.GOOGLE_API_KEY; + +const analyzeReviewInputSchema = z.object({ + rating: z.number(), + title: z.string(), + text: z.string(), + customer: z.string(), + emailType: z.string(), +}); + +type AnalyzeReviewInputOptions = z.infer; + +const analyzeEmailOutputSchema = z.object({ + subject: z.string(), + body: z.string(), +}); + +export type AnalyzeEmailOutputOptions = z.infer< + typeof analyzeEmailOutputSchema +>; + +export async function generateAISuggestedEmail( + options: AnalyzeReviewInputOptions +) { + const promptEmailBody = `Role: E-commerce customer care expert analyzing product reviews and outputting a result as a string. + + Task: Based on the input provided, generate a suggested email body response to the customer. + + Input Format: + + - "Title": The review title. + - "Body": The review body text. + - "Rating": The review rating, out of 5. + - "Customer": The customer's name. + - "Email Type": The type of email to send to the customer. This can be one of the following: "Thank you email" or "Follow-up email". + + Output Format: string + + Input Data: + - "Review Title:" ${options.title}, + - "Review Description": ${options.text}, + - "Review Rating": ${options.rating}, + - "Customer Name": ${options.customer}, + - "Email Type": ${options.emailType} + `; + + const promptEmailSubject = ( + emailBody: string + ) => `Role: E-commerce customer care expert analyzing product reviews and outputting a result as a string. + + Task: Based on the input provided, generate a suggested email subject for the provided email body. + + Input Format: + + - "Email body": The text of the customer care email. + + Output Format: string + + Input Data: + - "Email body": ${emailBody} +`; + + try { + const client = new TextServiceClient({ + authClient: new GoogleAuth().fromAPIKey(API_KEY), + }); + + const responseEmailBody = await client.generateText({ + model: MODEL_NAME, + prompt: { text: promptEmailBody }, + }); + + if (responseEmailBody?.[0]?.candidates) { + const outputEmailBody = responseEmailBody[0].candidates[0]?.output; + + const responseEmailSubject = await client.generateText({ + model: MODEL_NAME, + prompt: { + text: promptEmailSubject(outputEmailBody || ''), + }, + }); + + const outputEmailSubject = + responseEmailSubject?.[0]?.candidates && + responseEmailSubject?.[0]?.candidates[0]?.output; + + if (env.NODE_ENV === 'development') { + console.log( + '*** [Vertex Review Analysis Output Subject] ::', + outputEmailSubject + ); + console.log( + '*** [Vertex Review Analysis Output Body] ::', + outputEmailBody + ); + } + + if (outputEmailBody) { + const parsedOutput = analyzeEmailOutputSchema.safeParse({ + subject: outputEmailSubject, + body: outputEmailBody, + }); + + if (!parsedOutput.success) { + return 'Error parsing output'; + } + return parsedOutput.data; + } + } + } catch (error) { + console.error(error); + } + + return 'No response from Google AI'; +}