diff --git a/components/blog-post/blog-post-reviews-context.tsx b/components/blog-post/blog-post-reviews-context.tsx new file mode 100644 index 0000000..1746bb8 --- /dev/null +++ b/components/blog-post/blog-post-reviews-context.tsx @@ -0,0 +1,90 @@ +import React, { useReducer, useContext, createContext } from 'react'; + +type Action = + | { + type: 'FETCH_INIT'; + } + | { + type: 'FETCH_SUCCESS'; + payload: { + reviewCount: number; + ratingValue: number; + documentId: null | string; + }; + } + | { + type: 'FETCH_ERROR'; + }; +type Dispatch = (action: Action) => void; +type State = { + isLoading: boolean; + isError: boolean; + data: { + reviewCount: number; + ratingValue: number; + documentId: null | string; + }; +}; + +const PostReviewsStateContext = createContext(undefined); +const PostReviewsDispatchContext = createContext(undefined); + +const postReviewsContext = (state: State, action: Action): State => { + switch (action.type) { + case 'FETCH_INIT': + return { + ...state, + isError: false, + isLoading: true, + }; + case 'FETCH_SUCCESS': + return { + ...state, + isError: false, + isLoading: false, + data: action.payload || [], + }; + case 'FETCH_ERROR': + return { + ...state, + isError: true, + isLoading: false, + }; + } +}; + +const PostReviewsProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(postReviewsContext, { + isLoading: false, + isError: false, + data: { + reviewCount: 0, + ratingValue: -1, + documentId: null, + }, + }); + + return ( + + + {children} + + + ); +}; + +function usePostReviewsState(): State { + const context = useContext(PostReviewsStateContext); + if (context === undefined) { + throw new Error('usePostReviewsState must be used within a PostReviewsProvider'); + } + return context; +} +function usePostReviewsDispatch(): Dispatch { + const context = useContext(PostReviewsDispatchContext); + if (context === undefined) { + throw new Error('usePostReviewsDispatch must be used within a PostReviewsProvider'); + } + return context; +} +export { PostReviewsProvider, usePostReviewsState, usePostReviewsDispatch }; diff --git a/components/blog-post/sanity-client.ts b/components/blog-post/sanity-client.ts new file mode 100644 index 0000000..995b48c --- /dev/null +++ b/components/blog-post/sanity-client.ts @@ -0,0 +1,15 @@ +import sanityClient from '@sanity/client'; + +const client = sanityClient({ + projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID || '', + dataset: 'production', + token: process.env.NEXT_PUBLIC_SANITY_READ_TOKEN || '', + // Always use the freshest data (as we're going to save it to disk) + useCdn: false, +}); + +export const getPostReviews = async (postId: string): Promise<{ reviews: number[] }> => + await client.fetch(`*[_id == "${postId}"] {reviews[]}[0]`); + +// export const submitPostReview = async (postId: string, rating: number): Promise => +// await client.patch(postId).setIfMissing({ reviews: [] }).append('reviews', [rating]).commit(); diff --git a/pages/[categoryId]/[postId].tsx b/pages/[categoryId]/[postId].tsx index eb7c6a9..638e17d 100644 --- a/pages/[categoryId]/[postId].tsx +++ b/pages/[categoryId]/[postId].tsx @@ -1,8 +1,7 @@ -import React, { memo, useEffect, useMemo, useReducer } from 'react'; +import React, { memo, useEffect, useMemo } from 'react'; import { GetStaticProps, GetStaticPaths } from 'next'; import { useRouter } from 'next/router'; import ReactGA from 'react-ga'; -import sanityClient from '@sanity/client'; import PageMeta from '../../components/meta/PageMeta'; import AccessibleImage from '../../components/media/AccessibleImage'; @@ -13,6 +12,12 @@ import { AllSharingButtons } from '../../components/sharing/sharing-links'; import RichPortableText from '../../components/portable-text/RichPortableText'; import { ArticleContentContainer } from '../../components/layouts/Containers'; import { useNavVariantDispatch } from '../../components/nav/nav-variant-context'; +import { + PostReviewsProvider, + usePostReviewsState, + usePostReviewsDispatch, +} from '../../components/blog-post/blog-post-reviews-context'; +import { getPostReviews } from '../../components/blog-post/sanity-client'; import { joinUrl, postDateToHumanString } from '../../scripts/utils'; @@ -33,53 +38,6 @@ import { } from '../../typings'; const BLOG_POST_PAGE_ROUTE = '/[categoryId]/[postId]'; -const BLOG_POST_REVIEWS_QUERY = /* groq */ `*[_type == "tag"] { - _id, - name, - "slug": slug.current -}`; - -const client = sanityClient({ - projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID || '', - dataset: 'production', - token: process.env.NEXT_PUBLIC_SANITY_READ_TOKEN || '', - // Always use the freshest data (as we're going to save it to disk) - useCdn: false, -}); - -type DataFetchState = { - isLoading: boolean; - isError: boolean; - data: unknown[]; -}; -type DataFetchAction = { - type: 'FETCH_INIT' | 'FETCH_SUCCESS' | 'FETCH_ERROR'; - payload?: unknown[]; -}; - -const dataFetchReducer = (state: DataFetchState, action: DataFetchAction): DataFetchState => { - switch (action.type) { - case 'FETCH_INIT': - return { - ...state, - isError: false, - isLoading: true, - }; - case 'FETCH_SUCCESS': - return { - ...state, - isError: false, - isLoading: false, - data: action.payload || [], - }; - case 'FETCH_ERROR': - return { - ...state, - isError: true, - isLoading: false, - }; - } -}; const BasicArticleEl: React.FC = memo((props) =>
); BasicArticleEl.displayName = 'memo(BasicArticleEl)'; @@ -92,16 +50,9 @@ type PageBlogPostProps = { path: string; structuredData: StructuredData[]; }; -const BlogPost: NextComponentTypeWithLayout = ({ - blogPostData, - path, - structuredData, -}) => { - const [dataFetchState, dataFetchDispatch] = useReducer(dataFetchReducer, { - isLoading: false, - isError: false, - data: [], - }); +const BlogPostWrapped: React.FC = ({ blogPostData, path, structuredData }) => { + const postReviewsState = usePostReviewsState(); + const postReviewsDispatch = usePostReviewsDispatch(); const setVariant = useNavVariantDispatch(); useEffect(() => { setVariant('transparent'); @@ -114,22 +65,42 @@ const BlogPost: NextComponentTypeWithLayout = ({ [blogPostData] ); + if (structuredData.length && postReviewsState.data.reviewCount > 0) { + structuredData[structuredData.length - 1].aggregateRating = { + '@type': 'AggregateRating', + reviewCount: postReviewsState.data.reviewCount, + ratingValue: postReviewsState.data.ratingValue, + }; + } + useEffect(() => { const fetchData = async (): Promise => { - dataFetchDispatch({ type: 'FETCH_INIT' }); + postReviewsDispatch({ type: 'FETCH_INIT' }); try { - const reviews = await client.fetch(BLOG_POST_REVIEWS_QUERY); - dataFetchDispatch({ type: 'FETCH_SUCCESS', payload: reviews }); + const { reviews } = await getPostReviews(blogPostData._id); + + let ratingValue = -1; + if (reviews.length) { + // average + ratingValue = reviews.reduce((acc, curr) => acc + curr, 0) / reviews.length; + } + + postReviewsDispatch({ + type: 'FETCH_SUCCESS', + payload: { + ratingValue, + reviewCount: reviews.length, + documentId: blogPostData._id, + }, + }); } catch (error) { - dataFetchDispatch({ type: 'FETCH_ERROR' }); + postReviewsDispatch({ type: 'FETCH_ERROR' }); } }; fetchData(); - }, []); - - console.log(dataFetchState); + }, [postReviewsDispatch, blogPostData._id]); return ( <> @@ -261,6 +232,12 @@ const BlogPost: NextComponentTypeWithLayout = ({ ); }; +const BlogPost: NextComponentTypeWithLayout = (props) => ( + + + +); + export const getStaticPaths: GetStaticPaths = async () => { const blogPostRoute = routesConfig.find(({ route }) => route === '/[categoryId]/[postId]'); diff --git a/scripts/structured-data.ts b/scripts/structured-data.ts index a5c9366..e289592 100644 --- a/scripts/structured-data.ts +++ b/scripts/structured-data.ts @@ -180,13 +180,6 @@ export function generateRecipeStructuredData({ }, datePublished: blogPostData.datePublished, // Missing: video - // Missing: review[] - // TODO - // aggregateRating: { - // '@type': 'AggregateRating', - // reviewCount: 3, - // ratingValue: 4.5, - // }, }; }