From 81638e508a3316c237522ce4aee424e0a7af2fb3 Mon Sep 17 00:00:00 2001 From: Brad Adams Date: Sat, 12 Aug 2023 18:59:04 +0200 Subject: [PATCH 01/14] feat: update analye-review service + Add JSON response structure + Merge issue categorizing into review analysis prompt --- .../[productId]/review/[reviewId]/page.tsx | 17 ++-- src/components/IssueBadges.tsx | 22 ++--- src/components/ReviewDetail.tsx | 45 ++++++---- src/server/google-ai/analyze-review.ts | 86 +++++++++++++++---- 4 files changed, 113 insertions(+), 57 deletions(-) diff --git a/src/app/product/[productId]/review/[reviewId]/page.tsx b/src/app/product/[productId]/review/[reviewId]/page.tsx index 4975ec3..b52de9f 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 }; @@ -54,16 +51,12 @@ export default async function Page(props: PageProps) { title: review.title, }); - const issuesCategories = await analyzeIssuesCategory({ - rating: review.rating, - text: review.text, - title: review.title, - }); - return ( { +export const IssuesBadges = ({ + issuesCategoriesArray = [], +}: IssuesBadgesProps) => { return (
Issues found: - {issuesCategoriesArray.map((issue) => ( - - {issue} - - ))} + {issuesCategoriesArray.length > 0 ? ( + issuesCategoriesArray.map((issue) => ( + + )) + ) : ( + + )}
); }; diff --git a/src/components/ReviewDetail.tsx b/src/components/ReviewDetail.tsx index c72ad8f..933417f 100644 --- a/src/components/ReviewDetail.tsx +++ b/src/components/ReviewDetail.tsx @@ -16,6 +16,7 @@ import { IssuesBadges } from '~/components/IssueBadges'; import { ReviewStatusBadge } from '~/components/ReviewStatusBadge'; import { StarRating } from '~/components/StarRating'; +import { type AnalyzeReviewOutputValues } from '~/server/google-ai/analyze-review'; import { convertToDateString, convertToUDS } from '~/utils/utils'; interface ReviewDetailProps { @@ -23,17 +24,29 @@ interface ReviewDetailProps { customerReviews: Review[]; product: Product; review: Review; - sentimentAnalysis: string; - issuesCategories: string | undefined; + sentimentAnalysis?: AnalyzeReviewOutputValues; } +enum Sentiment { + NEGATIVE = 'NEGATIVE', + NEUTRAL = 'NEUTRAL', + POSITIVE = 'POSITIVE', +} + +const getSentimentString = (score: number) => { + if (score < 33) return Sentiment.NEGATIVE; + + if (score >= 33 && score < 66) return Sentiment.NEUTRAL; + + return Sentiment.POSITIVE; +}; + export const ReviewDetail = ({ customerOrders, customerReviews, product, review: reviewProp, sentimentAnalysis, - issuesCategories, }: ReviewDetailProps) => { const [review, setReview] = useState(reviewProp); @@ -57,10 +70,8 @@ export const ReviewDetail = ({ ); const formattedTotalSpendings = convertToUDS(totalCustomerSpendings); - const issuesCategoriesArray = useMemo( - () => issuesCategories?.split(',') ?? [], - [issuesCategories] - ); + const sentimentScore = sentimentAnalysis?.score ?? 0; + const sentimentString = getSentimentString(sentimentScore); return (
@@ -152,15 +163,13 @@ export const ReviewDetail = ({

Sentiment: = 2 && review.rating < 4, - 'text-green-500': review.rating >= 4, + className={clsx('capitalize', { + 'text-red-500': sentimentString === Sentiment.NEGATIVE, + 'text-yellow-300': sentimentString === Sentiment.NEUTRAL, + 'text-green-500': sentimentString === Sentiment.POSITIVE, })} > - {review.rating < 2 && 'Negative'} - {review.rating >= 2 && review.rating < 4 && 'Neutral'} - {review.rating >= 4 && 'Positive'} + {sentimentString.toLowerCase()}

@@ -175,19 +184,21 @@ export const ReviewDetail = ({ arc={{ colorArray: ['#ef4444', '#fde047', '#22c55e'] }} pointer={{ type: 'needle', color: '#9ca3af' }} type="semicircle" - value={review.rating * 20} + value={sentimentScore} />
- +
} hideAvatar diff --git a/src/server/google-ai/analyze-review.ts b/src/server/google-ai/analyze-review.ts index 1c9e4c0..696a269 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; + +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. + +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, +} +\`\`\` + +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,21 @@ 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 (output) { + const cleanOutput = output.replaceAll('```', ''); + const parsedOutput = analyzeReviewOutputSchema.safeParse( + JSON.parse(cleanOutput) + ); + + if (!parsedOutput.success) { + return 'Error parsing output'; + } + + return parsedOutput.data; + } } } catch (error) { console.error(error); From 61fbb775b5918f551fe62addf41b438f36d94c51 Mon Sep 17 00:00:00 2001 From: Brad Adams Date: Sat, 12 Aug 2023 19:04:52 +0200 Subject: [PATCH 02/14] fix: analyze-review prompt JSON structure + Include explicit instruction in prompt to use a JSON output --- src/server/google-ai/analyze-review.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/server/google-ai/analyze-review.ts b/src/server/google-ai/analyze-review.ts index 696a269..856eafe 100644 --- a/src/server/google-ai/analyze-review.ts +++ b/src/server/google-ai/analyze-review.ts @@ -44,7 +44,7 @@ const prepareInput = (options: AnalyzeReviewInputOptions): string => { export async function analyzeReview(options: AnalyzeReviewInputOptions) { // @todo: store results in firestore to avoid recalling the API for already-analysed reviews. - const prompt = `Role: E-commerce customer care expert analyzing product reviews. + 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. @@ -56,14 +56,12 @@ Input Format: 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, } -\`\`\` Output Format details: @@ -93,9 +91,8 @@ The review to analyze: const output = response[0].candidates[0]?.output; if (output) { - const cleanOutput = output.replaceAll('```', ''); const parsedOutput = analyzeReviewOutputSchema.safeParse( - JSON.parse(cleanOutput) + JSON.parse(output) ); if (!parsedOutput.success) { From 0b540154a5e87a388132f22ef07ab046a9b98e69 Mon Sep 17 00:00:00 2001 From: Brad Adams Date: Sat, 12 Aug 2023 19:16:08 +0200 Subject: [PATCH 03/14] fix: more analyze-review JSON instructions --- src/server/google-ai/analyze-review.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server/google-ai/analyze-review.ts b/src/server/google-ai/analyze-review.ts index 856eafe..d692635 100644 --- a/src/server/google-ai/analyze-review.ts +++ b/src/server/google-ai/analyze-review.ts @@ -60,9 +60,11 @@ 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, + "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. From 9f968c160352c207ae1b0cbac28e9c4c69d56953 Mon Sep 17 00:00:00 2001 From: Brad Adams Date: Sat, 12 Aug 2023 19:32:23 +0200 Subject: [PATCH 04/14] misc: chat bubble updates + replace jpg with png + Minor style updates --- public/images/vertex-ai.jpeg | Bin 4628 -> 0 bytes public/images/vertex-ai.png | Bin 0 -> 14105 bytes src/components/AIChatBubble.tsx | 18 ++++++------------ 3 files changed, 6 insertions(+), 12 deletions(-) delete mode 100644 public/images/vertex-ai.jpeg create mode 100644 public/images/vertex-ai.png diff --git a/public/images/vertex-ai.jpeg b/public/images/vertex-ai.jpeg deleted file mode 100644 index 96d90786fbcd109a9eec8c668854b2792e65c867..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4628 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 bCIwB7=oTR`<{KEu{Qs!Kzd{f~XA}PcVdr?^ diff --git a/public/images/vertex-ai.png b/public/images/vertex-ai.png new file mode 100644 index 0000000000000000000000000000000000000000..8ada1512e41edd702e3671630c3bccb3a7e8da37 GIT binary patch literal 14105 zcmeIZS5#A9^e(y+f+T_v0Y#J+>Ak8Hfe=Kh6a}OyC{217l#+lH5v5nDDvA{llp-yH z(wis>(pvzfgFr|~&f@pK5BK4YanBg%^*k`Lv-W1MHrF@j_syAjQ)AsDhff}cAn1s` z-i6B$1c!k?2zdy!och%C1$>}<^{fLRh?)K14>m9sL4_azNdJPCc}U*!n9svpkGsFt zT`SgA+SDdA@1>6Wz}6`=SyYf~IO)U0ES)HWG{<@c<1$*rNY?`MfUlik{3?k^tYd8cEhglDx<_X}yOoWOJH zG(4))`ULj}9eH%m^Pk5?kL=D9DL{ZPOU-dxdHSQVUn;~!w28bDl@ zq`~8M1&8{pLuwv^)3wydlyNVWgbVOBM{4Tb!QDPrrLu_qJ0j z2MSK8>+DJ4-V03~V6GqW;e2^mqWKRLt}4z*(BsQm*CLuJ&As$Q%RIwKoB)6NYd}wFCR{;d@u_L z+AYiQJ3~DG>-fYe`cXJU4Di=2a6b20`Zc7AhC|rMh-VJ1^Zc&9LUjsku)_yU@u10I zt4^Hn5el-lrj`DD<%0ALPZ>)Q_fUB%ExRA*rw09 zy+=`xgzvW#gT|epezz+3qEQ|%T{H8PV~D?8JWDB_@E=cror0m88q1JK-i;Q(z`Do6 zIdv-OZafCI)7qJ|m>7T=u#^Pq+X-ldb|_Kh{}@MZ2_T@zoWDZKNvu(@(+GhRW1pRL z?!NZP^M@-H7#a><|Cs$O=@V{_^paEO+tO+=S7l$*gcy&z+8#J7xoX>kinQD(fhUw! zwL~dbp@Xm<8|iw&ds!LdTYeQe;%ljy^;dW&>7I85O2{-NThkTViy zS^~~<+F%-EXW@LU>uVW>GKe(|DlhD3^mQ5Q=1yt5y}#=^K`+jc`?8j;5DmRNeT={) z&YSj1Vr#dx#zO`Y;)Yzi9ap+|7)13M1>UVT`IUf8uf0pHQN=+&7Fb0x(pLziF{fL^ zOP5PcB%$u?wv}3CB>|Y&@@mt%9_bVohJ(zL6~Zl_cZOC>L*9$ec_5dc&dpCeDx?M# zrHT+3$mWB>C#c((Jy5S!<+W420ja>S1+KtSUNCi~RKd)Y37o{Ji60j;;o}{oFtiXQ!H-mwRuK=+J;xXvDZf5t2xb(uAJeNpHt-0mkY*K3#p7qNA{%z*I_ zIB5bhTvb;;2A#Ial0Ym~+|m8htRuHIqstNv(T5w6w(l;q>kPGC>t_+wqIen)1m1{x z+0($BE%5h0B=DJYqmaFP#;2(y`NtiX)pIfZF$qOIUM*gYJ9-d?7yuABMyc-o)KH{% zXq9X;3)7G@WwXy;=RsQ7kj_z8LBxWC$L7hiJz8sa<63fpp3j%%0S>7z-p+iE@tBLO z#<9WHPGGPwZua)OwKj$YT*!Q8Nhd-K8lL|0cmS!CD)8)?%Io6+OUDmgF#7?(qh;+l z2AgGYs0FUwmicw~dOq=5kst0y|NM~#cd?k1v((rcA15>ym#<0V_8wXR%xX_-))I>V)Q%1}sf zTi;i^$t%t9wa?LVq zj=NCvlx=?+I)V4!Dp$%kCJQhKUJ5oInVhY4Orw<+1ACr&@Q}jyAMD2U z`V${6B^)#j0Fls!gx$aQ{|l}Qr%#}*1?^ML43xY}(@bv)KidI=_K`3cIM~9AVYk0| zyPb2T8wSg%B=c?#$#6mGrx9X5d5yZqB0Ol*5sajX(R8`;uQGR`BDMf{`X5#iQZIct z3g%Ei-s$^v4|T*G!gAvXIN-@SbiO@{PFjjQF9Tsw!1nx~G^P!hjbV@wf$zVlKwtVR zBp09?dlG^Q9B&q}0~o&~0&|%%3zi$+392ZP$~gZq!&l5u@b9wUVmf_uGry<)&y4Ij zT~S;8zVW#ITDseBR8#pDd!*4(_?DdW-W*HPi!;ISpn|vQFFQQ0YySOrn1J zfs08Ye)=@2&66WsA+ys3hFfJaU2PYE_thXF7ECJxIeUAL$O+R|90pQ-KP)Kd2TX6oS{sPQV0GUw3@>4@e(0 zJ(Vmj0XrPXl*Yx)#@J2a7`)m0YW)iL=7eV1QUerl)y}VB<7h+OZdrTs#ZmRs#-IMS zpd%krMEA+Mmye0np0y_!K+{F+lPcoDBZr|Bk4^Yx@KN7p;>;LZDFiK*prMDNU_^^E zS&(mb^E`hCa0&sC1R73trJP$1+gmt|k0yosE0y(vrZueJ)vsZ;5{ctU@dXK8WPkbSjDRljdOPb(+k*VsCDypXchV+)R zO_g@?azatCI&bPvA5K>3jJc%6iUm#JNUCnjZC2IgoqiH0Q13XyLtH)Zi*R5CM!lhV z>nTf}oAC0t6Jmi11p*;LeW*TgM232Oy@|b=C#?Ur!k;0G9W|iDXeEX|!n#cDl3HT@+;k3dPx_75;V6{zmtrB%^%t zNa!$ND5=c<9wFwD*=Zfpzu-h3TdrwG6sBsJYG-wtBv5PRBcx>Kh$9_=6O5yw=d;sJ z#&9-Az9-f}2wyqoNC+6*Tq;m)1^QJgsO0T`@J?w*Vqp6clkZJe#MFU!G3(W+jc)9 zJy$RwCDeWM%+{$G;oLsF^#LL3h0O=$pHIaUuyWDl?AZ2?AS3NwcC$BoEk-u^wHgPI zq8nEi_wXG41l*?%*~D{Pq=%f+vE@j`;Ro zm&UBmy(vCPg|!fY6$EgXrAhAM-VTm31mU(=SOl@xB|`G6z}EI|@Y&6W#~2LI@S?=@ zKWU9jHOeb{qui$3tHutd?B=TI6MNBH_`}sN zrQI+A2m=`pyZ7t|cyQ?7ua|(jcY#4}8jf7#(voJvr zS`5$;DDpJo|LgK;hOYw%rr$nV`#m}lKn}&ciyr1?HGKhM({>H+%7)X%9%|uoBceDP zHf@z{6cQUOXPx`^T+`inL5<8K0XBm-6ARXTk5dqKM&r+x70=#RO%uq;}rVJz2 zIP&RMjWSL~9-zK-`OG0efE}BFL)l&Whe3$_;5Bmh4-WjDJ#o5wCUDLVrfekha}0r} zr=0)E5k=65hMZ?{`$Es3-Nm7yQQ>2R4-xdET3G&r(}qYUp#)L|i2cxHcC8BJ7J6Fp zND*X+$Vk>FG(1dv;h1@HugV{5so$Nc_b&gIXqZ%h*tT=dY~$Cg$ML|w*@~Q8_9PAR z`#`EFHdtPXU>D02h->-Yv6w(z6@Q2c6-6vJJo9T73O`8TUD$uzTXFE068)5!nQ`>r zc8vm8l%)xk?n3|TnIGcns@S(-YN8jf5wB0k`siJdHg}Ka7+(bUkE2{-*7a|hdlcj3 zSlr9VMNeRHjxWvyGH*iJ@>&EHcw+K_bkLFWpL9@#CzN)x_WH*=&Dxy7{-nD8du(~FG!_v%Oui4e z@@fZr$A?vdiA|6v^W5@7?f(3`q20U!2lQVFc{je;99F@ql_Hy~Bd(j^^96@oD_rim zYp0AFK3Qm=B@yjaCDR(rlYI)kRT`VPwHti9Mb7RadQuZ`fj6R1EpjC9$r4U~lq96{ zhC_(*C~6t)>qI6CE{5LdI>4q!fRcd8R0nsB^%f$ai|)PSpDxH`4dtu6hI(%u zll}gqo!>1ooNC!*wqX6~sPFXdQ8o;g$yQYB=YtV0s;XZOm#@Rrt5DWQaU;9ZRoQHl zz|Fx2dV^(W1XYrsAc>44+)c4hV&>1ANY0?5w7ua#2U>Wa;ium}e6~4q%6RG886sj} zjHR$*j@TK!nxeKLmG$7~OZWm7Y5LTrTfJ|sIn4ewTY2C1{A4qGZO*%gE3s@q;V`ry zR0|>1yC+}WcIJa~0&xjIsu4qen>hbBZ=fZxEU=CQKPw$Oa`kfQecswpZTn{Z$H$Ix zKttk}kN!uhyd}S;5l~Pb#8Xk3gZI8ud7^|A)6RPLd3qFR7XH;W>7Qae<3fOn&=@SH z)^y_1?uC#-Xj1BDd|!${dCHpU*8(<}!;1;A?A%Cq+&r{-!s37Yp3^M{!N})VND~td zNTOSnMlQpv3(9RqzyF>)d`jUW-#qTtYpGz$Biz^OcwYtYrsJFHau#3Dkf4Q+fJtfN z*?#;5aea2`%0KisVu`1tB4EBR^-<#hfD*T3DxRxzTCb=msXR|g8-JQ@XbHq z!|4h7?_+yLJo+{MZnA`!j!@H-ri5Mwrpr@{<#4Pf4vPgy{NQ1hci#K4mB#L~6FbhL zSP)bV81LqCYs`)Jx{ME_{9VP*=~k}Nev8}6@b@;-Wu)nb_v+b1)v%4e#gZ6>3rNB* zBy8fUPg3PwStGYQErU-WD61_GNLKkh)Y});bbkV-02~}P%ck?x?&#QXx1v6aX%rd~ zJ6L3l&kSb4kE=IBY~|x6^pwfTCa^Q+HRziB#^j1WU%m zxs?ecxYJMNqW&NabF6F*@#>rEw#uN54CZYP<;>?#)mv#Wz=g1oQz_P?s?MQ7UBfGX z_X#x^f`bm=7w+(5zvKZI)vSXAO1&wNy&(Ai>+r6-HZ2PW|I(yQAbG?76MqKWYfv#Jjwo2G*PMGl3u2SuX_3y=r%nCn0LWKAYtl z2R69N&r{F7|G?g=1H@KxSe5LM(4vw{$=A$GJ_?Yw4iLI;w=XsTbKmPJ1F-N?wd^0W zh>)L2wDT@IHC|aQ5C(EhQKR?5x#(#|Cx$lu$<&VW{x6mF*Lw+tV;VvgJbM8sr5`I97w3r5ifC!QA`_Y zdJ#^HXci^v@vXk*c7qp-Cqe~P#G&L_9Uo(AmjG7Ge+x^bRxWp;Yh zfPAJWir7IiG)_hbihlj&ANPPD6AfKUUwaf+vVT2K_goQa zy5f_h253@N-_S*YAQ6Ni(%>fhr1Lu)<#_={phq9KX2?Sr!&w!BX@Jd{%KS;N6EG6k zb!kxd9vpFWjl(;sIAn4Wb8;)VBAGoA>=ASz+XlY0Ry}NaSh;g_zbhZlF1!)yavvMZ zVdu0NdTaljgFHgokP!|9t`K8`jkCs3tAr%usAK1@xDnbmSQ$B#S4_g8Syd4jz61Z& zH{hIEuKj0u-SUCqmrP-#vZ4t#^Uv_%&)>u^h2TPuP-*IbniMW`C}cpoKSmq_a1rSn zZ>)Pmj_>Js;_05YKnq%u}y#rfmy@P=`|%Jb$!qgwqb z@$sX`kOOCPS^0F%t9)l`YNT!#(|4E6bM3AKAWsqIiw`?E)*c@u1lKQ0A@eSPZUdIX z6QgWLOas7+C08qEv-=v_y(&K_~63kYRzWTU=f0d-M(%8 z_Lzjpg6H$RzgaP?p5rsk-M+bo$FEt*xNWK}TxJlKBA_R0-Q{(Zp|_=Tb7D~f94E8gKg9W}e9 zF(tRBDYDBCy~5~miO~m7n_>+HtdkP>z%WIPqfY-7_ODAc*IG+NIkm7%BseBaQIyWY zUm3O~F#@jEz9%$kz9aYQkkc6{h2lrAZHrGP&-oOQ2BK>sp7nQxL=|x#e!~6&bCxTKDL=@>05R_NA<@sz0Jh9t zE4Ry*6wiuTRXs^qGnSrHn;rwpIlE)OZB&!{`N~+365<9d8VYw8?`2c=ZwR{eF@K%V6tydUH7nGM<2Sm17@kIb1C`U>oW3!7YO~OVgV@-8Msn`8y{A zv;O$K-=&@RZ|`}#Wg`FqG}z#?dokQw;2AX#us;MCpGs-TS^E~iVLC{g z_OV-I6RCalrxTx3!gfLvmwNaOfiB(A8UJQw(V-VMVn+=UL?D0=>DjJdE;Q26YtIr_ z!T6p$k|i80(LpKqzZr2)WreHp(w-o~?pm#iP0A|0bu=-dZ*JA}@AX}P)=E1Rd(9b5 zwbTh*(j!7M773ldh$KEOR#h0;CdV-Fa^LtIG#&^clURbv|2j`*pFsYfrdbc+kQV_3 z%JFz+r#e?NZ!7-YN(bXsVypV?VhD-6v{~|u52q!0o*@{gI%a`tt@4i(wBFft zFW2pA9EJB<9;6tj=>n}SEYHM#gM8$>5B9PS3>f0+TJ39TO?P~{QUMi9osIf*PhV$c zRwi^lIHhgyrgztK$A^G#0de=#2DwahE1AiR)8ge}sTbpXWp+v|)s;D}$X+-O+e$L{ zo?&QL)O}5_FR$CKfg=dWh4QPbX`I)87nn2V)uPexh-?I5AHgu${T=~pCJVR6G@TyI z?uvdtj5dnHCiCL37iXuNctJLnvGChyyr&tjvi$>?gG-rBx5ec!sw4(ME#hP#K17i` z%n`jHK&hS$t6rF28=_M3d(N*0bXuLxjy#a>IQ~5af*hXm7Fb4Tz$ABCQHBd7=iN1r zYHQ~qC^2A#k}g@Su>9K&Iq)2(GIZrN<3e4m%yBZ8FCg_&tW$xo$~woKpUwoNPkWID zt@e$k>lats)VExF(;(=0(?Kl&ipSSbb`FxOyzZTdj1v7ZGV}Q1s^}TEmat5dIlmNk z9M)j=>4(Bewwnd74)b)*o~!35*{VU7WUnKnRJuNZV_Au1|ih-RhHzH zqog-{QZKF(Z3^tdx#SAh8{TL_wB1!d&K2K}N;ML;tP^F&1G-BYQ=h*sjFW2l>s(JB zu<*N*hL(V#)}2lb>$0#3+-T{aFul|hP3ok}FhTbMtZ+!!RL&8$TjC|d-X!+BS9RaC zf#ilKp4OIGs3{Mq=UmSyo?qFd%Z!wV4zpXR$XLHq)R5SEkyk}L=slF5|2|Op_vGwuB4Hh>Yg<9G zU2B9gNhZ}yk(ad_5yN+RL)CKc{B42lU!1CaK5@OPO+PXqpB^Sd=YX^0kb0R}!Hfa* zOL}6#GuHMBlY7azWZ=?=TFVB6#9>pND$KLYR6US&p(?NWpgB>aoDJX{F30v z6<@K+#OTJc#UBbMkJs2rlky~V4bDv7xM*@N@TEy>{oAO?>rG~Bq-y!AwCb@@DRIw` zOaUBrLn)}{@e#I=_i9;o+22bYmMjxgY&nkheXPux=oO3pX_w9Hn>w14b(s~en|P?` zN>31Nvw^BPW6o&6fWtP=;?kY16}+wo38nk(xf>jd1jyF=Z|6zohR)i`0V}#umVS?r z6K1t>;{@?@FJ9lz>qKwp|CD(M_*JZ2Z<)z=KY=FikN`1dre-&HPs=5__#H+5-$ zT>r^tE(*F{&ooC8TiluS1H+$&3(e6B7{cjkw}BSdXNqk`!o*X!x8n|1PlnmD26-7# zr79$+Eg$xMMihGGtWF(UgzpGM{t6S)hQ_740N5Vm*1ot{ z=IpqfXcdhnaCVHnY`x$e@;${=Lh%f_&d7PvN~1b6iHm;6#n39Jq@umJyt-@BsWkEz z$X;C{CD>Idl#5cJUH|o+Cz}&bk&yUlM6rulPPxFEvmLg#>fy2~}k zmB*4NW%C|)#LN`W%k=KG4YTuX`1OozK1k9wZNwm)^tR_$#d!Hb#7=EPdO7B|Rc*TH zE_`gK;Li8Z$@1KMdXA(jX4YZ}ViK2Ld?4+wm=v9DZo16-M5l8!WccY`{7>t8Z4}&d z0VZUmN_ShLtS%qsoHz=Xy4-k|0FVK5nYJoE8Y=XZ>cfR0RzR^#?v1jQ44#*-)5>b= z8Jv+wSYoo*9a|gx0&sNvs=kaB+fC13uJ_do=Ir+!i2}XE3Dpn2^XP7*EK!cwMS1SO z*|saM%$c{FMB)gJP9fN!J=YLO*|tI5^4hUt>MBUwJ_FGl&%f#(oh8r3`ouHp;0>&}R-g?H9;q;23$0)&%RAK^iHOPV^Pd7=3UzWXNI z`DGmw&Ru;jM%CK7)lPb3d#_t1)Kyua=>!UWahvT8kaSrjTdl0^vK%k2p_v>{O$B$r}|j0WD>&g&|Pr ze4IH{@O+mO5y=er%V2J(8SPgLx`>k()gm}I-@>w@5e&5OkNp#?Y3W&XZr00xY!-Y7 zmK8r6g&c!_j{=D#uHp`RqwJgE`QYNwBba#;-7mGC$0(AHCSG-1Wz>8CT|EesQN zNcE02@t)A8<9p?Vjb6NN1)1$#)3ImlXlOXANl4TElF`e>$A=6R#1)*Z%L=SA=i)x6 zlDQ}b3y6G>j5tmJ4 znUYz+ee)ab^4Bmv{d-)B(GZnTI~D2ZyxQ#lw;(fz&x=J9u5_#stQjKgr_`K`H~Q-C z#mCNhRhj&eSdnWVWk- z0c3QnVGkBZ-6W2wqF}^V>KMjH@9PDR`6h{cdO0$d0S!{|uiAN^v`$4zzX}V{`dRJz zvA)MHu>OQ<$_C!hZ317}<@%@&FZBRT7@94uEm>ogOm&Y#G4z_0D=3aRKehcG>G{h@ z)sU>hj+vt;4y=;2PtpZsa1guT%%Y8`?V!_%FKz*j<8F+Rf{H6kdlYnd6IifTP3;`2 z?hOBz);vh)1)yeW-WE4>aRFSltJ=CaYmVy%i;6>OZMPqTQ@|8R(@uSf01iyp$7E{z zO^O}Tqm+%U*z^#7spX5J%1S|VBmL+czBm$1_|fhicJkEw?40#uCb3N2uI0xZLx`hy zyeYo(uWG1xsX6>@n)WmjrkAnQH*w{6Fs;$^&5}|Mua8XfN6Ar4?gQPnxMPri*lS6X zC2HIUG(1@qKmZf)Ez?5MbN-vvw{M2gYpFSPJ>m8;ssm&bulC|q&4G(Ztc%ml{NKaB z2co#SQ=YJXY`K(9PhrGC3PArdHyH)A$7s|bXDP1V%*Og|oh;)V-YEu6=mu7$5J$5*upRZkj(i%$T2gc#T@~S!YvFo)j{D>r^GOa#OWSm znS1Z0J{S8VB$2RkV*s{iLsU|yU?=OPuy@E!Bts671sxjaoKzk-g*gY{W#$}kcgS>X zC*7Pd$%BLPTGt2>fi#9C41&lUDD*?$Z@%ft=ze8}Qr<-*;oO4W%eZv9#p+3YGQWlZ zf+*Rk#=g=~tTo8Rwn+VDWZyJe)&VcuX45pagnQywb zxNkz{9~VSmkEyc%E~(!vtlh2rF8Bka^Myf!nNO0%PFN9@%?p&<7{)UFxcw}^Ed(sp z!J9xNbP=ffSU^sd=iXEa0claWVe`%SqoiWeGJ%81mwM zUQ}04iuE&1%18|ksh~EQBj=DQvQo%$2w2CmDf1jYItopg1l4|nX}@Ue_`M&|`46bV z7G&j2XmGDHtJK2jzK)|sOfMdJ7z1eyoIStR@kVTKui9QWZsIOScyQi|k1G8-=l(*H zln!|(^Ky8>!*66ZEl`+fWuwZ@$J>-hqnx|0TiDpSedkzAWE?<=GvOx*Zv+qmA&s|% zsN4Jv@e%v@#zfj=-EDk}B)?3<5r$u-*5TX<%9+FTbox;#9JD5b3>lmyooia(TP~C= z#fv;>tXZ>-eX;m@XL$Q}H3#~TW}W?zZX z8Lk3tq*N?nDw4iW@p(%3UGPIqA}W)Jg?sNk+W5TM%d6S7r;Tu5I5h#mu0=|Rwr!0y8S+NU`6v&8vRt({y9y`?p$GE zePYDaoapz$2*pS2@1{hR^pkt>SUAWtL`??@DFwi2i+Hlqw_lqXve(sy$mB4$Z+M?( zVwXt?otCEmA@zsHgW6!CsR$id6(~G}^oE}DqLLEro4k(XMemgAe%GXM6q5h^qWxi_ ztH!t0?HkJO(06Ct-b%F9Ywpi5zvEO+hKN6#u(%;9q@LBsY{lSL9{%4xeQ5C!R%A&I zgygnq+i}nP=CWvaE+_4L8_X5R*}%s`kWhjFl>D@dl`WeFHBs$nOpr!`P&Xx~tRd{| zp3|qJJAUI$d^(zBvfe#6e*!>2wu_>*JMhO44*3K{m{L@vDU;B=x6dCzFO_ zPg109jY`b!1NCJFpd47aLm-SvaF=)8=BUB{t_-KBt;fyLW;;K|S@U!?)$UCBgzeG> z&P{9d?rz1PU)=O}1WBL=lwC;XA`((CmnPXLoL_4ICq@Hn_u}x{LRon7aLnfZUo+_y z$FPk*afzW>8=A?|U~d6o5bMzKNy*vJoMkS&wwEE5)NIZ$UdQ)n=6tK@L?M&gPuc`X zP5lCH7YI7SY6#h$^@sz_FVfe*W*Kp3&Yxa9R%5+*!-BN&1INfcZ*$UXX z6!1FvKGgACD=uuM^qS^wEnDxk@Z{J&yM&wPmdTpufm!BeP$Wo?qS?{sdvq18o z8k}-7hH$m)*$#L?uABLX^?E2(8%U8m`QMc2I}zlW!M91%JxUM8Y-$b?J)7>36iBxX zafQZ@wxumjbnl5>Zz!Od|Lj8vSpU~!-E8=l!d{m(UrY|098GEOa(A4H-ohMYg~mYT zQt_#~)wH#qLUJAb&rG+-pG((uo05qwAu1U5{%AB*GbOh2X7^swiyY4eN+o@5?oXG= z3(b6@wmd&n{t(>9q_`9J@#J;O-@2RYCEw}WE+?gh*Ff5h2#VMzV=t^sH___K@Zvog z@IhT!x-~oi_V5`fp!S6vxG|6~78KJ)*zy;L^Ch)5h-pJ2w z7=WLDxHbCFSD!5!v=(;p!60Ae065gvJ_>owkibt++*#{cp#U(~dwY*?hSvA;ZJNyP zkBOnM6`-w7!k=lmu5JHfWKSf-BrlKlwW?M5ovglnC7(a}v?T+?`ivb?Hmc&-4wBl} zd(92;fJ`Wl=<}_*f^cIIC_S4As(FNlLW)36yMf_#LHAK`+6zFTYC-Y?ta!ZlJ@Mbd z5JYphDKMFHkaE7Z>#ercc`4?mZRT0{8mJLpGY9>pUW)N7OT9$VwwlV+oHp!OrCwPV$mE2d2&c!$M`}i&LQf50XvCXCIA2c literal 0 HcmV?d00001 diff --git a/src/components/AIChatBubble.tsx b/src/components/AIChatBubble.tsx index bc36230..cfdfc98 100644 --- a/src/components/AIChatBubble.tsx +++ b/src/components/AIChatBubble.tsx @@ -7,28 +7,22 @@ interface AIChatBubbleProps { export const AIChatBubble = ({ message, hideAvatar }: AIChatBubbleProps) => { return ( -
+
{!hideAvatar && (
- AI Avatar -

Vertex AI

+ +

Vertex AI

)}
-
{message}
+
{message}
{!hideAvatar && ( -
+
)}
From 7e306ac29cbf69932f7797719a41c3af41d3bb4f Mon Sep 17 00:00:00 2001 From: Brad Adams Date: Sat, 12 Aug 2023 19:33:23 +0200 Subject: [PATCH 05/14] feat: review action buttons + Show buttons conditionally based on sentiment and status --- src/components/ReviewDetail.tsx | 75 +++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/src/components/ReviewDetail.tsx b/src/components/ReviewDetail.tsx index 933417f..931866a 100644 --- a/src/components/ReviewDetail.tsx +++ b/src/components/ReviewDetail.tsx @@ -16,6 +16,7 @@ import { IssuesBadges } from '~/components/IssueBadges'; import { ReviewStatusBadge } from '~/components/ReviewStatusBadge'; import { StarRating } from '~/components/StarRating'; +import { CloseIcon } from '@bigcommerce/big-design-icons'; import { type AnalyzeReviewOutputValues } from '~/server/google-ai/analyze-review'; import { convertToDateString, convertToUDS } from '~/utils/utils'; @@ -73,6 +74,10 @@ export const ReviewDetail = ({ const sentimentScore = sentimentAnalysis?.score ?? 0; const sentimentString = getSentimentString(sentimentScore); + const isPositive = sentimentString === Sentiment.POSITIVE; + const isNeutral = sentimentString === Sentiment.NEUTRAL; + const isNegative = sentimentString === Sentiment.NEGATIVE; + return (
@@ -164,9 +169,9 @@ export const ReviewDetail = ({ Sentiment: {sentimentString.toLowerCase()} @@ -190,8 +195,9 @@ export const ReviewDetail = ({
-
+
+
-
-
-

- Suggested Actions -

- -
- - - - - +
+ {review.status !== 'approved' && (isNeutral || isPositive) && ( + + )} + + {review.status !== 'disapproved' && isNegative && ( + + )} + + {isPositive && ( + + )} + + {(isNeutral || isNegative) && ( + + )}
From 73aa19d5721d6b07fd7fe9a74a85755366a5edcb Mon Sep 17 00:00:00 2001 From: Brad Adams Date: Sun, 13 Aug 2023 01:00:17 +0200 Subject: [PATCH 06/14] feat: review detail UI updates + Include `keywords` in UI + Adjust gauge section width --- src/components/ReviewDetail.tsx | 98 ++++++++++++++++++++++----------- 1 file changed, 65 insertions(+), 33 deletions(-) diff --git a/src/components/ReviewDetail.tsx b/src/components/ReviewDetail.tsx index 931866a..a218bcd 100644 --- a/src/components/ReviewDetail.tsx +++ b/src/components/ReviewDetail.tsx @@ -3,7 +3,7 @@ import { Box, Button, Tooltip } 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'; @@ -163,37 +163,13 @@ export const ReviewDetail = ({ />
-
- -

- Sentiment: - - {sentimentString.toLowerCase()} - -

-
- -
-
- +
+
@@ -211,7 +187,7 @@ export const ReviewDetail = ({ />
-
+
{review.status !== 'approved' && (isNeutral || isPositive) && (
+ + +
+

+ Sentiment{' '} + + {sentimentString.toLowerCase()} + +

+ +
+ +
+
+ +
+

+ Keywords +

+ +
+ {sentimentAnalysis?.keywords?.map((keyword) => ( +
+ {keyword} +
+ ))} +
+
+
); From 17a0ac67e061b92e515ad2aec3f1079957b1e0f4 Mon Sep 17 00:00:00 2001 From: Brad Adams Date: Sun, 13 Aug 2023 01:36:55 +0200 Subject: [PATCH 07/14] debug: add dev console.log + log review analysis response in dev mode --- src/server/google-ai/analyze-review.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/server/google-ai/analyze-review.ts b/src/server/google-ai/analyze-review.ts index d692635..e73225e 100644 --- a/src/server/google-ai/analyze-review.ts +++ b/src/server/google-ai/analyze-review.ts @@ -92,6 +92,10 @@ The review to analyze: 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) From d0e5f109ca95a4da95d92a67c030b40c55ddfc28 Mon Sep 17 00:00:00 2001 From: Brad Adams Date: Sun, 13 Aug 2023 01:43:51 +0200 Subject: [PATCH 08/14] chore: add TailwindIndicator component --- src/app/layout.tsx | 4 +++- src/components/TailwindIndicator.tsx | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 src/components/TailwindIndicator.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2a84c56..43e6b35 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({ @@ -25,6 +26,7 @@ export default function RootLayout({
{children}
+
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
+
+ ); +}; From 94614625031aebace8f741d276974e709e74094f Mon Sep 17 00:00:00 2001 From: Brad Adams Date: Sun, 13 Aug 2023 01:49:31 +0200 Subject: [PATCH 09/14] feat: review detail UI updates + Add title to ai chat bubbles section --- src/components/ReviewDetail.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/ReviewDetail.tsx b/src/components/ReviewDetail.tsx index a218bcd..804f2a6 100644 --- a/src/components/ReviewDetail.tsx +++ b/src/components/ReviewDetail.tsx @@ -165,12 +165,16 @@ export const ReviewDetail = ({
-
+

+ Feedback and Suggestions +

+ +
From 0cb8b5490c8f6df35a2ca397df8f5ca8262bd040 Mon Sep 17 00:00:00 2001 From: Brad Adams Date: Sun, 13 Aug 2023 02:02:46 +0200 Subject: [PATCH 10/14] feat: add "disapprove review" logic + Add helper and endpoint for disapproving reviews + Add loading states to approve/disapprove buttons --- src/app/api/disapprove-review/route.ts | 31 ++++++++++++++++++++++++++ src/components/ReviewDetail.tsx | 30 ++++++++++++++++++++++--- src/server/bigcommerce-api/index.ts | 22 ++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 src/app/api/disapprove-review/route.ts 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/components/ReviewDetail.tsx b/src/components/ReviewDetail.tsx index 804f2a6..30ef88d 100644 --- a/src/components/ReviewDetail.tsx +++ b/src/components/ReviewDetail.tsx @@ -50,8 +50,11 @@ 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({ @@ -61,7 +64,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( @@ -195,6 +214,7 @@ export const ReviewDetail = ({ {review.status !== 'approved' && (isNeutral || isPositive) && ( )} 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, From 1e5d822d902857a8742ad2a3e7df2efe848b76e0 Mon Sep 17 00:00:00 2001 From: Brad Adams Date: Sun, 13 Aug 2023 02:33:28 +0200 Subject: [PATCH 11/14] feat: store analysis results in firestore + Add db utils for reading and writing analysis data Closes #17 --- .../[productId]/review/[reviewId]/page.tsx | 32 ++++++++-- src/lib/db.ts | 64 +++++++++++++++++++ src/server/google-ai/analyze-review.ts | 2 +- 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/src/app/product/[productId]/review/[reviewId]/page.tsx b/src/app/product/[productId]/review/[reviewId]/page.tsx index b52de9f..329a00c 100644 --- a/src/app/product/[productId]/review/[reviewId]/page.tsx +++ b/src/app/product/[productId]/review/[reviewId]/page.tsx @@ -45,17 +45,37 @@ 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, }); + 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 ( { + 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); +} diff --git a/src/server/google-ai/analyze-review.ts b/src/server/google-ai/analyze-review.ts index e73225e..0524fac 100644 --- a/src/server/google-ai/analyze-review.ts +++ b/src/server/google-ai/analyze-review.ts @@ -14,7 +14,7 @@ const analyzeReviewInputSchema = z.object({ type AnalyzeReviewInputOptions = z.infer; -const analyzeReviewOutputSchema = z.object({ +export const analyzeReviewOutputSchema = z.object({ description: z.string(), issueCategories: z.array(z.string()), keywords: z.array(z.string()), From e61292de35c2bf4b45e57aae7a6ee461996845f7 Mon Sep 17 00:00:00 2001 From: Brad Adams Date: Sun, 13 Aug 2023 02:48:10 +0200 Subject: [PATCH 12/14] chore: misc. + Update app title + Adjust review detail responsive --- src/app/layout.tsx | 2 +- src/components/ReviewDetail.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 43e6b35..4a5a7a0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -10,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, diff --git a/src/components/ReviewDetail.tsx b/src/components/ReviewDetail.tsx index 30ef88d..79f4918 100644 --- a/src/components/ReviewDetail.tsx +++ b/src/components/ReviewDetail.tsx @@ -182,9 +182,9 @@ export const ReviewDetail = ({ />
-
+
Date: Sun, 13 Aug 2023 03:46:40 +0200 Subject: [PATCH 13/14] feat: add analysis data to review list + Add db util for loading all reviews in a product collection + Display sentiment score in review table + Display average sentiment for product (closes #18) + Extract `ScoreCircle` component and score parsing utils --- src/app/product/[productId]/page.tsx | 26 +++++++----- src/components/ProductReviewList.tsx | 26 ++++++++++++ src/components/ReviewDetail.tsx | 62 +++++++++++----------------- src/components/ScoreCircle.tsx | 26 ++++++++++++ src/lib/db.ts | 42 +++++++++++++++++++ src/utils/utils.ts | 25 ++++++++++- types/sentiment.ts | 5 +++ 7 files changed, 160 insertions(+), 52 deletions(-) create mode 100644 src/components/ScoreCircle.tsx create mode 100644 types/sentiment.ts 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/components/ProductReviewList.tsx b/src/components/ProductReviewList.tsx index 1decdd0..f1d873f 100644 --- a/src/components/ProductReviewList.tsx +++ b/src/components/ProductReviewList.tsx @@ -11,16 +11,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 +43,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 +107,24 @@ export const ProductReviewList = ({
} + topRightContent={} />
( + r.id === `${review.id}`)?.data + ?.score + } + /> + ), + }, { header: 'Rating', hash: 'rating', diff --git a/src/components/ReviewDetail.tsx b/src/components/ReviewDetail.tsx index 79f4918..90b115d 100644 --- a/src/components/ReviewDetail.tsx +++ b/src/components/ReviewDetail.tsx @@ -17,8 +17,9 @@ import { ReviewStatusBadge } from '~/components/ReviewStatusBadge'; 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 } from '~/utils/utils'; +import { convertToDateString, convertToUDS, parseScore } from '~/utils/utils'; interface ReviewDetailProps { customerOrders: Orders; @@ -28,20 +29,6 @@ interface ReviewDetailProps { sentimentAnalysis?: AnalyzeReviewOutputValues; } -enum Sentiment { - NEGATIVE = 'NEGATIVE', - NEUTRAL = 'NEUTRAL', - POSITIVE = 'POSITIVE', -} - -const getSentimentString = (score: number) => { - if (score < 33) return Sentiment.NEGATIVE; - - if (score >= 33 && score < 66) return Sentiment.NEUTRAL; - - return Sentiment.POSITIVE; -}; - export const ReviewDetail = ({ customerOrders, customerReviews, @@ -90,12 +77,8 @@ export const ReviewDetail = ({ ); const formattedTotalSpendings = convertToUDS(totalCustomerSpendings); - const sentimentScore = sentimentAnalysis?.score ?? 0; - const sentimentString = getSentimentString(sentimentScore); - - const isPositive = sentimentString === Sentiment.POSITIVE; - const isNeutral = sentimentString === Sentiment.NEUTRAL; - const isNegative = sentimentString === Sentiment.NEGATIVE; + const sentimentScore = sentimentAnalysis?.score; + const parsedScore = parseScore(sentimentScore); return (
@@ -171,8 +154,8 @@ export const ReviewDetail = ({ - 92 +
+
} > @@ -211,17 +194,18 @@ export const ReviewDetail = ({
- {review.status !== 'approved' && (isNeutral || isPositive) && ( - - )} + {review.status !== 'approved' && + (parsedScore.isNeutral || parsedScore.isPositive) && ( + + )} - {review.status !== 'disapproved' && isNegative && ( + {review.status !== 'disapproved' && parsedScore.isNegative && ( )} - {isPositive && ( + {parsedScore.isPositive && ( )} - {(isNeutral || isNegative) && ( + {(parsedScore.isNeutral || parsedScore.isNegative) && (
} - topRightContent={} + topRightContent={ + + } /> @@ -116,14 +122,23 @@ export const ProductReviewList = ({ { header: 'Score', hash: 'score', - render: (review) => ( - r.id === `${review.id}`)?.data - ?.score - } - /> - ), + render: (review) => { + const score = reviewAnalyses?.find( + (r) => r.id === `${review.id}` + )?.data?.score; + + return ( + + ); + }, }, { header: 'Rating', diff --git a/src/components/ReviewDetail.tsx b/src/components/ReviewDetail.tsx index 90b115d..020d06a 100644 --- a/src/components/ReviewDetail.tsx +++ b/src/components/ReviewDetail.tsx @@ -1,6 +1,6 @@ '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 { useState } from 'react'; @@ -151,16 +151,10 @@ export const ReviewDetail = ({ } topRightContent={ - - - - } - > - Overall customer sentiment score - + } /> diff --git a/src/components/ScoreCircle.tsx b/src/components/ScoreCircle.tsx index fd011ab..51d683c 100644 --- a/src/components/ScoreCircle.tsx +++ b/src/components/ScoreCircle.tsx @@ -1,26 +1,60 @@ +import { Tooltip } from '@bigcommerce/big-design'; import clsx from 'clsx'; +import { forwardRef, type ComponentProps } from 'react'; import { parseScore } from '~/utils/utils'; -interface ScoreCircleProps { +interface ScoreCircleBaseProps { + className?: string; score?: number; } -export const ScoreCircle = ({ score: scoreProp }: ScoreCircleProps) => { - const score = parseScore(scoreProp); +const ScoreCircleBase = forwardRef( + ({ className, score: scoreProp, ...props }, ref) => { + const score = parseScore(scoreProp); - return ( -
- {score.isNull ? '?' : 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 ; };