From c551e194372807a7ca7c7da1ea21361d29e5c92e Mon Sep 17 00:00:00 2001 From: Brad Adams Date: Tue, 15 Aug 2023 18:35:45 +0200 Subject: [PATCH 1/2] feat: more score averages + Add product review averages to main product list + Add average customer scores to review detail page --- .../[productId]/review/[reviewId]/page.tsx | 16 +++++ src/app/products/page.tsx | 7 +- src/components/ProductList.tsx | 39 ++++++++++- src/components/ReviewDetail.tsx | 4 +- src/lib/db.ts | 64 ++++++++++++++++--- 5 files changed, 117 insertions(+), 13 deletions(-) diff --git a/src/app/product/[productId]/review/[reviewId]/page.tsx b/src/app/product/[productId]/review/[reviewId]/page.tsx index 329a00c..d15748b 100644 --- a/src/app/product/[productId]/review/[reviewId]/page.tsx +++ b/src/app/product/[productId]/review/[reviewId]/page.tsx @@ -44,6 +44,7 @@ export default async function Page(props: PageProps) { }); const customerReviews = reviews.filter((r) => r.email === review.email); + const customerReviewIdStrings = customerReviews.map((r) => `${r.id}`); let sentimentAnalysis = await db.getReviewAnalysis({ productId, @@ -70,6 +71,20 @@ export default async function Page(props: PageProps) { } } + const allAnalyses = await db.getAllReviewAnalyses({ + storeHash: authorized.storeHash, + }); + + const allUserAnalyses = + allAnalyses?.filter((analysis) => + customerReviewIdStrings.includes(analysis.reviewId) + ) ?? []; + + const userAverageScore = allUserAnalyses.length + ? allUserAnalyses.reduce((acc, analysis) => acc + analysis.data.score, 0) / + allUserAnalyses.length + : undefined; + return ( ); } diff --git a/src/app/products/page.tsx b/src/app/products/page.tsx index b60bc6d..7dff962 100644 --- a/src/app/products/page.tsx +++ b/src/app/products/page.tsx @@ -18,7 +18,10 @@ export default async function Page() { throw new Error('Access token not found. Try to re-install the app.'); } - const products = await fetchAllProducts(accessToken, authorized.storeHash); + const [products, allReviews] = await Promise.all([ + fetchAllProducts(accessToken, authorized.storeHash), + db.getAllReviewAnalyses({ storeHash: authorized.storeHash }), + ]); - return ; + return ; } diff --git a/src/components/ProductList.tsx b/src/components/ProductList.tsx index dc9f717..260dbbe 100644 --- a/src/components/ProductList.tsx +++ b/src/components/ProductList.tsx @@ -12,9 +12,12 @@ import { NextLink } from '~/components/NextLink'; import { ProductFilters } from '~/components/ProductFilters'; import { StarRating } from '~/components/StarRating'; +import { ScoreCircle } from '~/components/ScoreCircle'; +import { type AllReviewAnalysesResponse } from '~/lib/db'; import { convertToUDS } from '~/utils/utils'; interface ProductListProps { + allAnalyses: AllReviewAnalysesResponse; products: SimpleProduct[]; } @@ -38,7 +41,7 @@ const ReviewsAverage = ({ product }: ReviewsAverageProps) => { return ; }; -const ProductList = ({ products }: ProductListProps) => { +const ProductList = ({ allAnalyses, products }: ProductListProps) => { const [filteredProducts, setFilteredProducts] = useState(products); const sortedProductsByReviews = useMemo( () => @@ -48,11 +51,35 @@ const ProductList = ({ products }: ProductListProps) => { [filteredProducts] ); + const productScoresById = products?.reduce( + (acc, { id: productId }) => { + const reviewAnalyses = + allAnalyses?.filter((review) => review.productId === `${productId}`) ?? + []; + + const productScore = reviewAnalyses?.length + ? Math.floor( + reviewAnalyses.reduce( + (acc, analysis) => acc + analysis.data.score, + 0 + ) / reviewAnalyses.length + ) + : undefined; + + return { + ...acc, + [productId]: productScore, + }; + }, + {} as Record + ); + return (
All Products +
{ hash: 'name', render: (product) => product.name, }, + { + header: 'Score', + hash: 'score', + render: (product) => ( + + ), + }, { header: 'Price', hash: 'price', diff --git a/src/components/ReviewDetail.tsx b/src/components/ReviewDetail.tsx index c037079..a3851ac 100644 --- a/src/components/ReviewDetail.tsx +++ b/src/components/ReviewDetail.tsx @@ -32,6 +32,7 @@ interface ReviewDetailProps { product: Product; review: Review; sentimentAnalysis?: AnalyzeReviewOutputValues; + userAverageScore?: number; } export const ReviewDetail = ({ @@ -40,6 +41,7 @@ export const ReviewDetail = ({ product, review: reviewProp, sentimentAnalysis, + userAverageScore, }: ReviewDetailProps) => { const [review, setReview] = useState(reviewProp); @@ -125,7 +127,7 @@ export const ReviewDetail = ({ } topRightContent={ } diff --git a/src/lib/db.ts b/src/lib/db.ts index 84fd0ab..4ecddb6 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -7,7 +7,6 @@ import { getDocs, getFirestore, setDoc, - updateDoc, } from 'firebase/firestore'; import { z } from 'zod'; import { env } from '~/env.mjs'; @@ -161,25 +160,31 @@ export async function setReviewAnalysis({ }) { if (!storeHash) return null; - const ref = doc( + const productRef = doc( db, 'reviewAnalysis', storeHash, 'products', - `${productId}`, - 'reviews', - `${reviewId}` + `${productId}` ); - await setDoc(ref, analysis); + const reviewRef = doc(productRef, 'reviews', `${reviewId}`); + + await Promise.all([ + // Empty product document so we can reference it as a collection in queries + setDoc(productRef, {}), + + // @todo: include customerId in firestore + setDoc(reviewRef, analysis), + ]); } -const reviewAnalysesListSchema = z.array( +const reviewAnalysesByProductIdSchema = z.array( z.object({ id: z.string(), data: analyzeReviewOutputSchema }) ); export type ReviewAnalysesByProductIdResponse = Zod.infer< - typeof reviewAnalysesListSchema + typeof reviewAnalysesByProductIdSchema >; export async function getReviewAnalysesByProductId({ @@ -202,7 +207,7 @@ export async function getReviewAnalysesByProductId({ const snapshot = await getDocs(ref); - const parsedAnalyses = reviewAnalysesListSchema.safeParse( + const parsedAnalyses = reviewAnalysesByProductIdSchema.safeParse( snapshot.docs.map((doc) => ({ id: doc.id, data: doc.data() })) ); @@ -212,3 +217,44 @@ export async function getReviewAnalysesByProductId({ return parsedAnalyses.data; } + +const allReviewAnalysesSchema = z.array( + z.object({ + productId: z.string(), + reviewId: z.string(), + data: analyzeReviewOutputSchema, + }) +); + +export type AllReviewAnalysesResponse = Zod.infer< + typeof allReviewAnalysesSchema +>; + +export async function getAllReviewAnalyses({ + storeHash, +}: { + storeHash: string; +}): Promise { + if (!storeHash) return null; + + const reviews: AllReviewAnalysesResponse = []; + + const productsRef = collection(db, 'reviewAnalysis', storeHash, 'products'); + const productsSnapshot = await getDocs(productsRef); + + for (const product of productsSnapshot.docs) { + const reviewsRef = collection(product.ref, 'reviews'); + + const reviewsSnapshot = await getDocs(reviewsRef); + + for (const review of reviewsSnapshot.docs) { + reviews.push({ + data: review.data() as AllReviewAnalysesResponse[number]['data'], + productId: product.id, + reviewId: review.id, + }); + } + } + + return reviews; +} From 7968bd50b80120c69377a3e876db4bc8ae1c8179 Mon Sep 17 00:00:00 2001 From: Brad Adams Date: Tue, 15 Aug 2023 18:42:54 +0200 Subject: [PATCH 2/2] chore: remove example app --- .../productDescription/[productId]/form.tsx | 147 ------------------ .../[productId]/generator.tsx | 33 ---- .../[productId]/loading.tsx | 7 - .../productDescription/[productId]/page.tsx | 45 ------ src/lib/appExtensions.ts | 60 ------- 5 files changed, 292 deletions(-) delete mode 100644 src/app/productDescription/[productId]/form.tsx delete mode 100644 src/app/productDescription/[productId]/generator.tsx delete mode 100644 src/app/productDescription/[productId]/loading.tsx delete mode 100644 src/app/productDescription/[productId]/page.tsx diff --git a/src/app/productDescription/[productId]/form.tsx b/src/app/productDescription/[productId]/form.tsx deleted file mode 100644 index 98d8658..0000000 --- a/src/app/productDescription/[productId]/form.tsx +++ /dev/null @@ -1,147 +0,0 @@ -'use client'; - -import { usePromptAttributes } from '~/context/PromptAttributesContext'; -import { useDescriptionsHistory } from '~/hooks'; -import { useState } from 'react'; -import { type NewProduct, type Product } from 'types'; -import styled from 'styled-components'; -import { Box, Button, Flex, FlexItem } from '@bigcommerce/big-design'; -import AiResults from '~/components/AiResults/AiResults'; -import { CustomPromptForm } from '~/components/PromptForm/CustomPromptForm'; -import { GuidedPromptForm } from '~/components/PromptForm/GuidedPromptForm'; -import { StyledButton } from '~/components/PromptForm/styled'; -import { prepareAiPromptAttributes } from '~/utils/utils'; -import Loader from '~/components/Loader'; - -const Hr = styled(Flex)` - margin-left: -${({ theme }) => theme.spacing.xLarge}; - margin-right: -${({ theme }) => theme.spacing.xLarge}; -`; - -export default function Form({ product }: { product: Product | NewProduct }) { - const { results, setResults, handleDescriptionChange } = - useDescriptionsHistory(product.id); - const [isPrompting, setIsPrompting] = useState(false); - const [description, setDescription] = useState( - results.at(0)?.description || '' - ); - - const { - isFormGuided, - setIsFormGuided, - currentAttributes, - guidedAttributes, - customAttributes, - setGuidedAttributes, - setCustomAttributes, - } = usePromptAttributes(); - - const handleGenerateDescription = async () => { - setIsPrompting(true); - const res = await fetch('/api/generateDescription', { - method: 'POST', - body: JSON.stringify( - prepareAiPromptAttributes(currentAttributes, product) - ), - }); - - if (!res.ok) { - setIsPrompting(false); - throw new Error('Cannot generate description, try again later'); - } - - const { description } = (await res.json()) as { description: string }; - setResults({ promptAttributes: currentAttributes, description }); - setIsPrompting(false); - }; - - const descriptionChangeWrapper = (index: number, description: string) => { - setDescription(description); - handleDescriptionChange(index, description); - }; - - const handleCancelClick = () => - window.top?.postMessage( - JSON.stringify({ namespace: 'APP_EXT', action: 'CLOSE' }), - '*' - ); - const handleUseThisClick = () => - window.top?.postMessage( - JSON.stringify({ - namespace: 'APP_EXT', - action: 'PRODUCT_DESCRIPTION', - data: { description }, - }), - '*' - ); - - return ( - - - - setIsFormGuided(true)} - > - Guided - - setIsFormGuided(false)} - > - Custom - - - {isFormGuided ? ( - - ) : ( - - )} - - - - - {isPrompting && } - {!isPrompting && ( - <> -
- - - - - - - )} -
- ); -} diff --git a/src/app/productDescription/[productId]/generator.tsx b/src/app/productDescription/[productId]/generator.tsx deleted file mode 100644 index 4f1a12f..0000000 --- a/src/app/productDescription/[productId]/generator.tsx +++ /dev/null @@ -1,33 +0,0 @@ -'use client'; - -import { PromptAttributesProvider } from '~/context/PromptAttributesContext'; -import { useDescriptionsHistory } from '~/hooks'; -import { useEffect, useState } from 'react'; -import { type NewProduct, type Product } from 'types'; -import { DEFAULT_GUIDED_ATTRIBUTES } from '~/constants'; -import Form from './form'; - -export default function Generator({ - product, - initialDescription, -}: { - product: Product | NewProduct; - initialDescription: string; -}) { - const [isInitialLoad, setInitialLoad] = useState(false); - const { setResults } = useDescriptionsHistory(product.id); - - useEffect(() => { - setResults({ - description: initialDescription, - promptAttributes: DEFAULT_GUIDED_ATTRIBUTES, - }); - setInitialLoad(true); - }, [initialDescription, setResults]); - - return ( - - {isInitialLoad &&
} - - ); -} diff --git a/src/app/productDescription/[productId]/loading.tsx b/src/app/productDescription/[productId]/loading.tsx deleted file mode 100644 index e99db1f..0000000 --- a/src/app/productDescription/[productId]/loading.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use client'; - -import Loader from '~/components/Loader'; - -export default function Loading() { - return ; -} diff --git a/src/app/productDescription/[productId]/page.tsx b/src/app/productDescription/[productId]/page.tsx deleted file mode 100644 index dc5a166..0000000 --- a/src/app/productDescription/[productId]/page.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Suspense } from 'react'; -import Loader from '~/components/Loader'; -import { fetchProductWithAttributes } from '~/server/bigcommerce-api'; -import generateDescription from '~/server/google-ai'; -import { authorize } from '~/lib/authorize'; -import * as db from '~/lib/db'; -import Generator from './generator'; - -interface PageProps { - params: { productId: string }; - searchParams: { product_name: string }; -} - -export default async function Page(props: PageProps) { - const { productId } = props.params; - const { product_name: name } = props.searchParams; - - const authorized = authorize(); - - if (!authorized) { - throw new Error('Token is not valid. Try to re-open the app.'); - } - - const accessToken = await db.getStoreToken(authorized.storeHash); - - if (!accessToken) { - throw new Error('Access token not found. Try to re-install the app.'); - } - - const id = Number(productId); - - // cover case when product is not created yet - const product = - id === 0 - ? { id, name: name || '' } - : await fetchProductWithAttributes(id, accessToken, authorized.storeHash); - - const description = await generateDescription({ product }); - - return ( - }> - - - ); -} diff --git a/src/lib/appExtensions.ts b/src/lib/appExtensions.ts index abff370..df0bd64 100644 --- a/src/lib/appExtensions.ts +++ b/src/lib/appExtensions.ts @@ -37,27 +37,6 @@ export const createAppExtension = async ({ if (errors && errors.length > 0) { throw new Error(errors[0]?.message); } - - // Create the review app extension - - const response2 = await fetch( - `${BIGCOMMERCE_API_URL}/stores/${storeHash}/graphql`, - { - method: 'POST', - headers: { - accept: 'application/json', - 'content-type': 'application/json', - 'x-auth-token': accessToken, - }, - body: JSON.stringify(createReviewsAppExtensionMutation()), - } - ); - - const { errors: errors2 } = await response2.json(); - - if (errors2 && errors2.length > 0) { - throw new Error(errors2[0]?.message); - } }; export const getAppExtensions = async ({ @@ -102,45 +81,6 @@ const getAppExtensionsQuery = () => ({ }); const createAppExtensionMutation = () => ({ - query: ` - mutation AppExtension($input: CreateAppExtensionInput!) { - appExtension { - createAppExtension(input: $input) { - appExtension { - id - context - model - url - label { - defaultValue - locales { - value - localeCode - } - } - } - } - } - }`, - variables: { - input: { - context: 'PANEL', - model: 'PRODUCT_DESCRIPTION', - url: '/productDescription/${id}', - label: { - defaultValue: 'Generate text', - locales: [ - { - value: 'Generate text', - localeCode: 'en-US', - }, - ], - }, - }, - }, -}); - -const createReviewsAppExtensionMutation = () => ({ query: ` mutation AppExtension($input: CreateAppExtensionInput!) { appExtension {