diff --git a/src/components/forms/find/index.tsx b/src/components/forms/find/index.tsx
index 1628249..2522f45 100644
--- a/src/components/forms/find/index.tsx
+++ b/src/components/forms/find/index.tsx
@@ -2,7 +2,7 @@ import { ChangeEventHandler, FormEventHandler, useRef, useState } from 'react';
import { useRouter } from 'next/router';
import { cleanQueryStr } from 'src/utils/helpers';
import { QueryTypes } from 'src/interfaces/shared/search';
-import { resultTypes, resultTitleTypes } from 'src/utils/constants/find';
+import { resultTypes, resultTitleTypes, findFilterable } from 'src/utils/constants/find';
import styles from 'src/styles/modules/components/form/find.module.scss';
type Props = {
@@ -29,13 +29,15 @@ const Form = ({ className }: Props) => {
const formEl = formRef.current!;
const formData = new FormData(formEl);
- const query = (formData.get('q') as string).trim();
+ const query = formData.get('q');
+ if (typeof query !== 'string' || !query.trim()) return setIsDisabled(false);
- const entries = [...formData.entries()] as [string, string][];
- const queryStr = cleanQueryStr(entries);
+ const queryParams = Object.fromEntries(
+ formData.entries() as IterableIterator<[string, string]>
+ );
+ const queryStr = cleanQueryStr(queryParams, findFilterable);
- if (query) router.push(`/find?${queryStr}`);
- else setIsDisabled(false);
+ router.push(`/find?${queryStr}`);
formEl.reset();
};
@@ -117,5 +119,4 @@ const RadioBtns = ({
>
);
-
export default Form;
diff --git a/src/components/titleReviews/Card.tsx b/src/components/titleReviews/Card.tsx
new file mode 100644
index 0000000..1f3315a
--- /dev/null
+++ b/src/components/titleReviews/Card.tsx
@@ -0,0 +1,46 @@
+import { useRouter } from 'next/router';
+import { CardResult } from 'src/components/card';
+import TitleReviews, { TitleReviewsCursored } from 'src/interfaces/shared/titleReviews';
+
+type Props = {
+ meta: TitleReviews['meta'];
+ className?: string;
+};
+
+const Card = ({ meta, className }: Props) => {
+ return (
+
+ User Reviews
+ {meta.numReviews}
+
+ );
+};
+
+type BasicCardProps = {
+ meta: TitleReviewsCursored['meta'];
+ className?: string;
+};
+export const BasicCard = ({ meta, className }: BasicCardProps) => {
+ const { titleId } = useRouter().query;
+
+ return (
+
+ User Reviews
+
+ );
+};
+
+export default Card;
diff --git a/src/components/titleReviews/Filters.tsx b/src/components/titleReviews/Filters.tsx
new file mode 100644
index 0000000..be4b8a1
--- /dev/null
+++ b/src/components/titleReviews/Filters.tsx
@@ -0,0 +1,86 @@
+import { FormEventHandler, useRef } from 'react';
+import { useRouter } from 'next/router';
+import { cleanQueryStr } from 'src/utils/helpers';
+import { direction, keys, ratings, sortBy } from 'src/utils/constants/titleReviewsFilters';
+import styles from 'src/styles/modules/components/titleReviews/form.module.scss';
+
+type Props = {
+ className?: string;
+ titleId: string;
+};
+
+const Filters = ({ className, titleId }: Props) => {
+ const router = useRouter();
+ const formRef = useRef
(null);
+
+ const submitHandler: FormEventHandler = e => {
+ e.preventDefault();
+
+ const formEl = formRef.current!;
+ const formData = new FormData(formEl);
+
+ const entries = Object.fromEntries(formData.entries()) as Record;
+ const queryStr = cleanQueryStr(entries, keys);
+
+ router.push(`/title/${titleId}/reviews?${queryStr}`);
+ };
+
+ return (
+
+ );
+};
+
+const RadioBtns = ({
+ data,
+ className,
+}: {
+ data: typeof ratings | typeof sortBy | typeof direction;
+ className: string;
+}) => (
+ <>
+ {data.types.map(({ name, val }) => (
+
+
+
+
+ ))}
+ >
+);
+
+export default Filters;
diff --git a/src/components/titleReviews/Pagination.tsx b/src/components/titleReviews/Pagination.tsx
new file mode 100644
index 0000000..5a4f361
--- /dev/null
+++ b/src/components/titleReviews/Pagination.tsx
@@ -0,0 +1,34 @@
+import TitleReviews, { TitleReviewsCursored } from 'src/interfaces/shared/titleReviews';
+import styles from 'src/styles/modules/components/titleReviews/pagination.module.scss';
+import Link from 'next/link';
+import { ReactNode } from 'react';
+import { useRouter } from 'next/router';
+import { cleanQueryStr } from 'src/utils/helpers';
+import { direction, keys, ratings, sortBy } from 'src/utils/constants/titleReviewsFilters';
+
+type Props = {
+ meta: TitleReviewsCursored['meta'];
+ cursor: string | null;
+ onClick?: (queryStr: string) => void;
+};
+
+const Pagination = ({ cursor, onClick = () => {}, meta }: Props) => {
+ const router = useRouter();
+
+ if (!cursor || !meta.titleId) return null;
+ const queryParams = router.query as Record;
+ const queryStr = cleanQueryStr(queryParams, keys);
+
+ return (
+ <>
+
+
+ Load More
+
+ >
+ );
+};
+
+export default Pagination;
diff --git a/src/components/titleReviews/Reviews.tsx b/src/components/titleReviews/Reviews.tsx
new file mode 100644
index 0000000..51ee93e
--- /dev/null
+++ b/src/components/titleReviews/Reviews.tsx
@@ -0,0 +1,82 @@
+import TitleReviews from 'src/interfaces/shared/titleReviews';
+import styles from 'src/styles/modules/components/titleReviews/reviews.module.scss';
+import Link from 'next/link';
+import { ReactNode } from 'react';
+
+type Props = {
+ list: TitleReviews['list'];
+ className?: string;
+ children?: ReactNode;
+};
+
+const Results = ({ list, className, children }: Props) => {
+ return (
+
+
+ {list.map(review => (
+
+ ))}
+
+ {children}
+
+ );
+};
+
+const Review = ({
+ by,
+ date,
+ isSpoiler,
+ rating,
+ responses,
+ reviewHtml,
+ reviewId,
+ summary,
+}: TitleReviews['list'][number]) => {
+ return (
+
+
+
+
+ {by.name}
+
+
+
+ {isSpoiler && (
+
+
+ Spoilers
+
+ )}
+ {rating && (
+
+
+ {rating}
+
+ )}
+
+
+ {summary}
+
+ {reviewHtml && (
+
+ )}
+
+
+
+ );
+};
+
+export default Results;
diff --git a/src/components/titleReviews/index.ts b/src/components/titleReviews/index.ts
new file mode 100644
index 0000000..8f706d2
--- /dev/null
+++ b/src/components/titleReviews/index.ts
@@ -0,0 +1,5 @@
+export { default as Reviews } from './Reviews';
+export { default as TitleCard } from './Card';
+export * from './Card';
+export { default as Filters } from './Filters';
+export { default as Pagination } from './Pagination';
diff --git a/src/interfaces/misc/rawName.ts b/src/interfaces/misc/rawName.ts
index f180423..fb75506 100644
--- a/src/interfaces/misc/rawName.ts
+++ b/src/interfaces/misc/rawName.ts
@@ -562,7 +562,7 @@ export default interface Name {
};
}>;
totalCredits: {
- total: number;
+ total?: number;
// restriction?: {
// unrestrictedTotal: number;
// explanations: Array<{
diff --git a/src/interfaces/shared/titleReviews.ts b/src/interfaces/shared/titleReviews.ts
new file mode 100644
index 0000000..104d53c
--- /dev/null
+++ b/src/interfaces/shared/titleReviews.ts
@@ -0,0 +1,6 @@
+import reviews, { cursoredReviews } from 'src/utils/fetchers/titleReviews';
+
+type TitleReviews = Awaited>;
+export type { TitleReviews as default };
+
+export type TitleReviewsCursored = Awaited>;
diff --git a/src/pages/api/find.ts b/src/pages/api/find.ts
index 2fcee7f..8190387 100644
--- a/src/pages/api/find.ts
+++ b/src/pages/api/find.ts
@@ -4,6 +4,7 @@ import basicSearch from 'src/utils/fetchers/basicSearch';
import getOrSetApiCache from 'src/utils/getOrSetApiCache';
import { findKey } from 'src/utils/constants/keys';
import { AppError, cleanQueryStr } from 'src/utils/helpers';
+import { findFilterable } from 'src/utils/constants/find';
type ResponseData =
| { status: true; data: { title: null | string; results: null | Find } }
@@ -13,15 +14,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
try {
if (req.method !== 'GET') throw new AppError('Invalid method', 400);
- const queryObj = req.query as FindQueryParams | Record;
+ const queryObj = req.query as FindQueryParams;
const query = queryObj.q?.trim();
if (!query) {
return res.status(200).json({ status: true, data: { title: null, results: null } });
}
- const entries = Object.entries(queryObj);
- const queryStr = cleanQueryStr(entries);
+ const queryStr = cleanQueryStr(queryObj, findFilterable);
const results = await getOrSetApiCache(findKey(queryStr), basicSearch, queryStr);
res.status(200).json({ status: true, data: { title: query, results } });
diff --git a/src/pages/api/title/[titleId].ts b/src/pages/api/title/[titleId]/index.ts
similarity index 100%
rename from src/pages/api/title/[titleId].ts
rename to src/pages/api/title/[titleId]/index.ts
diff --git a/src/pages/api/title/[titleId]/reviews/[paginationKey].ts b/src/pages/api/title/[titleId]/reviews/[paginationKey].ts
new file mode 100644
index 0000000..60c695a
--- /dev/null
+++ b/src/pages/api/title/[titleId]/reviews/[paginationKey].ts
@@ -0,0 +1,33 @@
+import type { NextApiRequest, NextApiResponse } from 'next';
+import type { TitleReviewsCursored } from 'src/interfaces/shared/titleReviews';
+import { cursoredReviews } from 'src/utils/fetchers/titleReviews';
+import getOrSetApiCache from 'src/utils/getOrSetApiCache';
+import { titleReviewsCursoredKey } from 'src/utils/constants/keys';
+import { AppError, cleanQueryStr } from 'src/utils/helpers';
+import { keys as titleReviewsQueryKeys } from 'src/utils/constants/titleReviewsFilters';
+
+type ResponseData =
+ | { status: true; data: TitleReviewsCursored }
+ | { status: false; message: string };
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ if (req.method !== 'GET') throw new AppError('Invalid method', 400);
+
+ const titleId = req.query.titleId as string;
+ const paginationKey = req.query.paginationKey as string;
+ const queryObj = req.query as Record;
+ const queryStr = cleanQueryStr(queryObj, titleReviewsQueryKeys);
+ const data = await getOrSetApiCache(
+ titleReviewsCursoredKey(titleId, paginationKey),
+ cursoredReviews,
+ titleId,
+ paginationKey,
+ queryStr
+ );
+ res.status(200).json({ status: true, data });
+ } catch (error: any) {
+ const { message = 'Not found', statusCode = 404 } = error;
+ res.status(statusCode).json({ status: false, message });
+ }
+}
diff --git a/src/pages/api/title/[titleId]/reviews/index.ts b/src/pages/api/title/[titleId]/reviews/index.ts
new file mode 100644
index 0000000..9965a27
--- /dev/null
+++ b/src/pages/api/title/[titleId]/reviews/index.ts
@@ -0,0 +1,21 @@
+import type { NextApiRequest, NextApiResponse } from 'next';
+import type Title from 'src/interfaces/shared/title';
+import title from 'src/utils/fetchers/title';
+import getOrSetApiCache from 'src/utils/getOrSetApiCache';
+import { titleKey } from 'src/utils/constants/keys';
+import { AppError } from 'src/utils/helpers';
+
+type ResponseData = { status: true; data: Title } | { status: false; message: string };
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ if (req.method !== 'GET') throw new AppError('Invalid method', 400);
+
+ const titleId = req.query.titleId as string;
+ const data = await getOrSetApiCache(titleKey(titleId), title, titleId);
+ res.status(200).json({ status: true, data });
+ } catch (error: any) {
+ const { message = 'Not found', statusCode = 404 } = error;
+ res.status(statusCode).json({ status: false, message });
+ }
+}
diff --git a/src/pages/find/index.tsx b/src/pages/find/index.tsx
index e406b92..0a7e203 100644
--- a/src/pages/find/index.tsx
+++ b/src/pages/find/index.tsx
@@ -11,6 +11,7 @@ import getOrSetApiCache from 'src/utils/getOrSetApiCache';
import { cleanQueryStr } from 'src/utils/helpers';
import { findKey } from 'src/utils/constants/keys';
import styles from 'src/styles/modules/pages/find/find.module.scss';
+import { findFilterable } from 'src/utils/constants/find';
type Props = InferGetServerSidePropsType;
@@ -58,8 +59,7 @@ export const getServerSideProps: GetServerSideProps = asy
if (!query) return { props: { data: { title: null, results: null }, error: null, originalPath } };
try {
- const entries = Object.entries(queryObj);
- const queryStr = cleanQueryStr(entries);
+ const queryStr = cleanQueryStr(queryObj, findFilterable);
const res = await getOrSetApiCache(findKey(queryStr), basicSearch, queryStr);
diff --git a/src/pages/title/[titleId]/reviews/[paginationKey]/index.tsx b/src/pages/title/[titleId]/reviews/[paginationKey]/index.tsx
new file mode 100644
index 0000000..180610d
--- /dev/null
+++ b/src/pages/title/[titleId]/reviews/[paginationKey]/index.tsx
@@ -0,0 +1,72 @@
+import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
+import Meta from 'src/components/meta/Meta';
+import Layout from 'src/components/layout';
+import ErrorInfo from 'src/components/error/ErrorInfo';
+import { BasicCard, Filters, Pagination, Reviews, TitleCard } from 'src/components/titleReviews';
+import { AppError } from 'src/interfaces/shared/error';
+import getOrSetApiCache from 'src/utils/getOrSetApiCache';
+import titleReviews, { cursoredReviews } from 'src/utils/fetchers/titleReviews';
+import { cleanQueryStr, getProxiedIMDbImgUrl } from 'src/utils/helpers';
+import { titleKey, titleReviewsKey } from 'src/utils/constants/keys';
+import styles from 'src/styles/modules/pages/titleReviews/titleReviews.module.scss';
+import TitleReviews, { TitleReviewsCursored } from 'src/interfaces/shared/titleReviews';
+import { keys as titleReviewsQueryKeys } from 'src/utils/constants/titleReviewsFilters';
+
+type Props = InferGetServerSidePropsType;
+
+const CursoredReviewsPage = ({ data, error, originalPath }: Props) => {
+ if (error) return ;
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+// TO-DO: make a getServerSideProps wrapper for handling errors
+type Data = ({ data: TitleReviewsCursored; error: null } | { error: AppError; data: null }) & {
+ originalPath: string;
+};
+type Params = { titleId: string; paginationKey: string };
+
+export const getServerSideProps: GetServerSideProps = async ctx => {
+ const titleId = ctx.params!.titleId;
+ const paginationKey = ctx.params!.paginationKey;
+ const title = ctx.query.title as string | null;
+
+ const originalPath = `/title/${titleId}/reviews/_ajax?paginationKey=${paginationKey}`;
+ const queryObj = ctx.query as Record;
+ const queryStr = cleanQueryStr(queryObj, titleReviewsQueryKeys);
+
+ try {
+ const data = await getOrSetApiCache(
+ titleKey(titleId),
+ cursoredReviews,
+ titleId,
+ paginationKey,
+ queryStr,
+ title
+ );
+
+ return { props: { data, error: null, originalPath } };
+ } catch (error: any) {
+ const { message, statusCode } = error;
+ ctx.res.statusCode = statusCode;
+ ctx.res.statusMessage = message;
+
+ return { props: { error: { message, statusCode }, data: null, originalPath } };
+ }
+};
+
+export default CursoredReviewsPage;
diff --git a/src/pages/title/[titleId]/reviews/index.tsx b/src/pages/title/[titleId]/reviews/index.tsx
new file mode 100644
index 0000000..1944118
--- /dev/null
+++ b/src/pages/title/[titleId]/reviews/index.tsx
@@ -0,0 +1,92 @@
+import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
+import Meta from 'src/components/meta/Meta';
+import Layout from 'src/components/layout';
+import ErrorInfo from 'src/components/error/ErrorInfo';
+import { Filters, Pagination, Reviews, TitleCard } from 'src/components/titleReviews';
+import { AppError } from 'src/interfaces/shared/error';
+import getOrSetApiCache from 'src/utils/getOrSetApiCache';
+import titleReviews, { cursoredReviews } from 'src/utils/fetchers/titleReviews';
+import { cleanQueryStr, getProxiedIMDbImgUrl } from 'src/utils/helpers';
+import { titleKey, titleReviewsKey } from 'src/utils/constants/keys';
+import styles from 'src/styles/modules/pages/titleReviews/titleReviews.module.scss';
+import TitleReviews from 'src/interfaces/shared/titleReviews';
+import { keys as titleReviewFiltersQueryKeys } from 'src/utils/constants/titleReviewsFilters';
+import { useState } from 'react';
+import ProgressBar from 'src/components/loaders/ProgressBar';
+
+type Props = InferGetServerSidePropsType;
+
+// TO-DO: make a wrapper page component to display errors, if present in props
+const ReviewsPage = ({ data, error, originalPath }: Props) => {
+ const [allData, setAllData] = useState({ list: data?.list ?? [], cursor: data?.cursor ?? null });
+ const [isFetching, setIsFetching] = useState(false);
+
+ if (error) return ;
+
+ const handleOnClickLoadMore = (queryStr = '') => {
+ if (!data.cursor) return;
+ setIsFetching(true);
+ fetch(`/api/title/${data.meta.titleId}/reviews/${allData.cursor}?${queryStr}`)
+ .then(res => {
+ if (!res.ok) throw new Error('something went wrong');
+ return res.json();
+ })
+ .then(newData =>
+ setAllData(prev => ({
+ list: prev.list.concat(newData.data.list),
+ cursor: newData.data.cursor ?? null,
+ }))
+ )
+ .catch(console.log)
+ .finally(() => setIsFetching(false));
+ };
+
+ return (
+ <>
+ {isFetching && }
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+// TO-DO: make a getServerSideProps wrapper for handling errors
+type Data = ({ data: TitleReviews; error: null } | { error: AppError; data: null }) & {
+ originalPath: string;
+};
+type Params = { titleId: string };
+
+export const getServerSideProps: GetServerSideProps = async ctx => {
+ const titleId = ctx.params!.titleId;
+ const originalPath = ctx.resolvedUrl;
+ const queryParams = ctx.query as Record;
+ const queryStr = cleanQueryStr(queryParams, titleReviewFiltersQueryKeys);
+ try {
+ const data = await getOrSetApiCache(
+ titleReviewsKey(titleId, queryStr),
+ titleReviews,
+ titleId,
+ queryStr
+ );
+
+ return { props: { data, error: null, originalPath } };
+ } catch (error: any) {
+ const { message, statusCode } = error;
+ ctx.res.statusCode = statusCode;
+ ctx.res.statusMessage = message;
+
+ return { props: { error: { message, statusCode }, data: null, originalPath } };
+ }
+};
+
+export default ReviewsPage;
diff --git a/src/styles/modules/components/card/card-title-reviews.module.scss b/src/styles/modules/components/card/card-title-reviews.module.scss
new file mode 100644
index 0000000..648802f
--- /dev/null
+++ b/src/styles/modules/components/card/card-title-reviews.module.scss
@@ -0,0 +1,80 @@
+@use '../../../abstracts' as helper;
+
+.title {
+ line-height: 1;
+}
+
+.meta {
+ list-style: none;
+ display: flex;
+ flex-wrap: wrap;
+
+ & * + *::before {
+ content: '\00b7';
+ padding-inline: var(--spacer-0);
+ font-weight: 900;
+ line-height: 0;
+ font-size: var(--fs-5);
+ }
+
+ @include helper.bp('bp-900') {
+ justify-content: center;
+ }
+}
+
+.ratings {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--spacer-0) var(--spacer-3);
+
+ @include helper.bp('bp-900') {
+ justify-content: center;
+ }
+}
+
+.rating {
+ font-size: var(--fs-5);
+
+ display: grid;
+ grid-template-columns: repeat(2, max-content);
+ place-items: center;
+ gap: 0 var(--spacer-0);
+
+ &__num {
+ grid-column: 1 / 2;
+ font-size: 1.8em;
+ font-weight: var(--fw-medium);
+ // line-height: 1;
+ }
+
+ &__icon {
+ --dim: 1.8em;
+ grid-column: -2 / -1;
+ line-height: 1;
+ height: var(--dim);
+ width: var(--dim);
+ display: grid;
+ place-content: center;
+ fill: var(--clr-fill);
+ }
+
+ &__text {
+ grid-column: 1 / -1;
+ font-size: 0.9em;
+ line-height: 1;
+
+ color: var(--clr-text-muted);
+ }
+}
+
+.link {
+ @include helper.prettify-link(var(--clr-link));
+}
+
+.genres,
+.overview,
+.crewType {
+ &__heading {
+ font-weight: var(--fw-bold);
+ }
+}
diff --git a/src/styles/modules/components/titleReviews/form.module.scss b/src/styles/modules/components/titleReviews/form.module.scss
new file mode 100644
index 0000000..b5b09b2
--- /dev/null
+++ b/src/styles/modules/components/titleReviews/form.module.scss
@@ -0,0 +1,111 @@
+@use '../../../abstracts' as helper;
+
+.form {
+ display: grid;
+ gap: var(--spacer-2);
+
+ position: sticky;
+ top: var(--spacer-2);
+
+ @include helper.bp('bp-1200') {
+ position: initial;
+ }
+}
+
+%border-styles {
+ border-radius: var(--spacer-1);
+ border: 2px solid var(--clr-fill-muted);
+}
+
+.fieldset {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--spacer-2);
+ padding: var(--spacer-2);
+
+ @extend %border-styles;
+
+ &__heading {
+ font-size: var(--fs-4);
+ padding-inline: var(--spacer-1);
+ flex: 100%;
+ line-height: 1;
+ color: var(--clr-text-muted);
+ }
+
+ &:disabled {
+ &, * {
+ cursor: not-allowed;
+ filter: brightness(.95);
+ }
+ }
+}
+
+.radio {
+ --border-color: transparent;
+ position: relative;
+ display: inline-flex;
+
+ label {
+ cursor: pointer;
+ padding: var(--spacer-1) var(--spacer-2);
+ border-radius: 5px;
+ color: var(--clr-text-accent);
+ background-color: var(--clr-bg-accent);
+ border: 2px solid var(--border-color);
+ }
+
+ input:checked + label {
+ --border-color: var(--clr-text-accent);
+ }
+
+ // for keyboard navigation
+ input:focus + label {
+ @include helper.focus-rules;
+ }
+
+ @supports selector(:focus-visible) {
+ input:focus + label {
+ outline: none;
+ }
+
+ input:focus-visible + label {
+ @include helper.focus-rules;
+ }
+ }
+}
+
+.exact {
+ display: flex;
+ gap: var(--spacer-1);
+ justify-self: start;
+ align-items: center;
+
+ label, input {
+ cursor: pointer;
+ }
+}
+
+.buttons {
+ display: flex;
+ gap: var(--spacer-2);
+}
+
+.button {
+
+ --text: var(--clr-link);
+
+ padding: var(--spacer-1) var(--spacer-2);
+ font: inherit;
+
+ background: transparent;
+ color: var(--text);
+ border: 2px solid currentColor;
+ border-radius: 5px;
+ cursor: pointer;
+
+ &[type='reset'] {
+ --text: var(--clr-text-muted)
+
+ }
+}
\ No newline at end of file
diff --git a/src/styles/modules/components/titleReviews/pagination.module.scss b/src/styles/modules/components/titleReviews/pagination.module.scss
new file mode 100644
index 0000000..2b300c8
--- /dev/null
+++ b/src/styles/modules/components/titleReviews/pagination.module.scss
@@ -0,0 +1,22 @@
+@use '../../../abstracts' as helper;
+
+.button, .link {
+ --text: var(--clr-link);
+
+ padding: var(--spacer-1) var(--spacer-2);
+ font: inherit;
+ text-decoration: none;
+
+ background: transparent;
+ color: var(--text);
+ border: 2px solid currentColor;
+ border-radius: 5px;
+ cursor: pointer;
+
+}
+
+:root {
+ &:not([data-js]) .button, &[data-js] .link {
+ display: none;
+ }
+}
diff --git a/src/styles/modules/components/titleReviews/reviews.module.scss b/src/styles/modules/components/titleReviews/reviews.module.scss
new file mode 100644
index 0000000..4f5b429
--- /dev/null
+++ b/src/styles/modules/components/titleReviews/reviews.module.scss
@@ -0,0 +1,120 @@
+@use '../../../abstracts' as helper;
+
+.reviews {
+ display: grid;
+ // gap: var(--spacer-5);
+
+ > * + * {
+ border-top: .5px solid var(--clr-fill-muted);
+ }
+ > * {
+ --padding: var(--spacer-5);
+ padding-block: var(--padding);
+
+ @include helper.bp('bp-700') {
+ --padding: var(--spacer-3);
+ }
+
+ }
+
+ :first-child {
+ padding-top: 0;
+ }
+}
+
+.review {
+ &__summary {
+ font-size: calc(var(--fs-5) * 1.1);
+ cursor: pointer;
+
+ display: grid;
+ grid-template-areas: 'name rating' 'date rating' 'summary summary';
+ align-items: stretch;
+ justify-items: start;
+
+
+
+ a {
+ grid-area: name;
+ }
+ time {
+ grid-area: date;
+ font-size: .8em;
+ color: var(--clr-text-muted);
+ }
+
+ strong {
+ grid-area: summary;
+ margin-top: var(--spacer-21);
+ }
+
+ &_chevron {
+ --dim: 1.2em;
+ }
+
+ // @include helper.bp('bp-700') {
+ // grid-template-areas: 'name' 'date' 'rating' 'summary';
+ // }
+ }
+
+ &__misc {
+ grid-area: rating;
+ align-self: center;
+ justify-self: self-end;
+ // background-color: red;
+
+ display: grid;
+ grid-auto-flow: column;
+ gap: var(--spacer-2);
+
+ span {
+ display: flex;
+ gap: var(--spacer-0);
+ align-items: center;
+ @include helper.bp('bp-700') {
+ font-size: .9em;
+
+ }
+ }
+
+ &_spoilers {
+ color: var(--clr-link-muted);
+ @include helper.bp('bp-700') {
+
+ }
+ }
+
+ @include helper.bp('bp-700') {
+ // justify-self: stretch;
+ // padding-block: var(--spacer-0);
+ grid-auto-flow: row;
+ grid-template-columns: repeat(2, max-content);
+ gap: 0;
+ }
+ }
+
+ &__text,
+ &__metadata {
+ padding-top: var(--spacer-2);
+ }
+
+ &__summary_chevron {
+ @include helper.bp('bp-700') {
+ grid-column: -2 / -1;
+ grid-row: 1 / 3;
+ place-self: center;
+ }
+ }
+
+ &[open] &__summary_chevron {
+ transform: rotate(180deg);
+
+ }
+}
+
+:where(.icon) {
+ --dim: 1em;
+ height: var(--dim);
+ width: var(--dim);
+ fill: var(--clr-fill);
+}
diff --git a/src/styles/modules/pages/titleReviews/titleReviews.module.scss b/src/styles/modules/pages/titleReviews/titleReviews.module.scss
new file mode 100644
index 0000000..ac8f63f
--- /dev/null
+++ b/src/styles/modules/pages/titleReviews/titleReviews.module.scss
@@ -0,0 +1,43 @@
+@use '../../../abstracts' as helper;
+
+.container {
+ // major whitespace properties used on title page
+ --doc-whitespace: var(--spacer-8);
+ --comp-whitespace: var(--spacer-3);
+
+ display: grid;
+
+ gap: var(--doc-whitespace);
+ padding: var(--doc-whitespace);
+ align-items: start;
+
+ grid-template-columns: repeat(5, 1fr);
+ grid-template-areas:
+ 'card card card form form'
+ 'results results results form form';
+
+ @include helper.bp('bp-900') {
+ grid-template-columns: none;
+ grid-template-areas: 'card' 'results' 'form';
+ }
+
+ @include helper.bp('bp-700') {
+ --doc-whitespace: var(--spacer-5);
+ }
+
+ @include helper.bp('bp-450') {
+ --doc-whitespace: var(--spacer-3);
+ }
+}
+
+.card {
+ grid-area: card;
+}
+
+.results {
+ grid-area: results;
+}
+
+.form {
+ grid-area: form;
+}
diff --git a/src/utils/cleaners/name.ts b/src/utils/cleaners/name.ts
index 72fbef9..1482e46 100644
--- a/src/utils/cleaners/name.ts
+++ b/src/utils/cleaners/name.ts
@@ -126,7 +126,7 @@ const cleanName = (rawData: RawName) => {
},
})),
credits: {
- total: misc.totalCredits.total,
+ total: misc.totalCredits?.total ?? null,
summary: {
titleType: misc.creditSummary.titleTypeCategories.map(cat => ({
total: cat.total,
diff --git a/src/utils/constants/find.ts b/src/utils/constants/find.ts
index e3e1f7b..b4d8cd8 100644
--- a/src/utils/constants/find.ts
+++ b/src/utils/constants/find.ts
@@ -34,3 +34,5 @@ export const resultTitleTypes = {
],
key: 'ttype',
} as const;
+
+export const findFilterable = ['q', 'exact', resultTitleTypes.key, resultTypes.key];
diff --git a/src/utils/constants/keys.ts b/src/utils/constants/keys.ts
index 3e1bcf3..9cff9cd 100644
--- a/src/utils/constants/keys.ts
+++ b/src/utils/constants/keys.ts
@@ -1,4 +1,7 @@
export const titleKey = (titleId: string) => `title:${titleId}`;
+export const titleReviewsKey = (titleId: string, query = '') => `title:${titleId}|${query}`;
+export const titleReviewsCursoredKey = (titleId: string, paginationKey: string) =>
+ `title:${titleId}|${paginationKey}`;
export const nameKey = (nameId: string) => `name:${nameId}`;
export const listKey = (listId: string, pageNum = '1') => `list:${listId}?page=${pageNum}`;
export const findKey = (query: string) => `find:${query}`;
diff --git a/src/utils/constants/titleReviewsFilters.ts b/src/utils/constants/titleReviewsFilters.ts
new file mode 100644
index 0000000..07246dd
--- /dev/null
+++ b/src/utils/constants/titleReviewsFilters.ts
@@ -0,0 +1,36 @@
+export const ratings = {
+ types: [
+ { name: 'All', val: '0' },
+ { name: '1', val: '1' },
+ { name: '2', val: '2' },
+ { name: '3', val: '3' },
+ { name: '4', val: '4' },
+ { name: '5', val: '5' },
+ { name: '6', val: '6' },
+ { name: '7', val: '7' },
+ { name: '8', val: '8' },
+ { name: '9', val: '9' },
+ { name: '10', val: '10' },
+ ],
+ key: 'ratingFilter',
+} as const;
+
+export const sortBy = {
+ types: [
+ { name: 'Featured', val: 'curated' },
+ { name: 'Review Date', val: 'submissionDate' },
+ { name: 'Total Votes', val: 'totalVotes' },
+ { name: 'Prolific Reviewer', val: 'reviewVolume' },
+ { name: 'Review Rating', val: 'userRating' },
+ ],
+ key: 'sort',
+} as const;
+
+export const direction = {
+ types: [
+ { name: 'Ascending', val: 'asc' },
+ { name: 'Descending', val: 'desc' },
+ ],
+ key: 'dir',
+} as const;
+export const keys = ['spoiler', direction.key, sortBy.key, ratings.key];
\ No newline at end of file
diff --git a/src/utils/fetchers/titleReviews.ts b/src/utils/fetchers/titleReviews.ts
new file mode 100644
index 0000000..e28c4c3
--- /dev/null
+++ b/src/utils/fetchers/titleReviews.ts
@@ -0,0 +1,113 @@
+import { AxiosError } from 'axios';
+import * as cheerio from 'cheerio';
+import axiosInstance from 'src/utils/axiosInstance';
+import { AppError } from 'src/utils/helpers';
+
+const reviews = async (titleId: string, queryStr = '') => {
+ try {
+ // https://www.imdb.com/title/tt0364343/reviews?spoiler=hide&sort=curated&dir=desc&ratingFilter=0
+ const res = await axiosInstance(`/title/${titleId}/reviews?${queryStr}`);
+ const $ = cheerio.load(res.data);
+
+ const $main = $('#main > .article');
+ const $meta = $main.children('.subpage_title_block');
+
+ const meta = {
+ title: clean($meta.find('[itemprop="name"] > a')),
+ year: clean($meta.find('[itemprop="name"] > .nobr')),
+ image: $meta.find('img[itemprop="image"]').attr('src') ?? null,
+ numReviews: clean($main.find('.lister > .header > div')),
+ titleId,
+ };
+
+ const $listItems = $main.find('.lister-list').children();
+
+ const list = getReviewsList($listItems, $);
+ const cursor = $main.find('.lister > .load-more-data').attr('data-key') ?? null;
+
+ return { meta, list, cursor };
+ } catch (err) {
+ if (err instanceof AxiosError && err.response?.status === 404)
+ throw new AppError('not found', 404, err.cause);
+
+ if (err instanceof AppError) throw err;
+
+ throw new AppError('something went wrong', 500, err instanceof Error ? err.cause : undefined);
+ }
+};
+
+export default reviews;
+
+const clean = >(item: T) => item.text().trim();
+
+export const cursoredReviews = async (
+ titleId: string,
+ paginationKey: string,
+ queryStr = '',
+ title: string | null = null
+) => {
+ try {
+ // https://www.imdb.com/title/tt0364343/reviews/_ajax?paginationKey=g4w6ddbmqyzdo6ic4oxwjnjqrtt4yaz53iptz6pna7cpyv35pjt6udc2oiyfzmrkb4drv33tz5tbvxqxw25ns6mwx3qym&sort=desc
+ const res = await axiosInstance(
+ `/title/${titleId}/reviews/_ajax?paginationKey=${paginationKey}&${queryStr}`
+ );
+
+ const $ = cheerio.load(res.data);
+ const $main = $('body > div');
+ const $listItems = $main.children('.lister-list').children();
+
+ const list = getReviewsList($listItems, $);
+
+ const cursor = $main.children('.load-more-data').attr('data-key') ?? null;
+
+ return { meta: { title, titleId }, list, cursor };
+ } catch (err) {
+ if (err instanceof AxiosError && err.response?.status === 404)
+ throw new AppError('not found', 404, err.cause);
+
+ if (err instanceof AppError) throw err;
+
+ throw new AppError('something went wrong', 500, err instanceof Error ? err.cause : undefined);
+ }
+};
+
+const getReviewsList = ($listItems: cheerio.Cheerio, $: cheerio.CheerioAPI) => {
+ return $listItems
+ .map((_i, el) => {
+ const reviewId = $(el).attr('data-review-id') ?? null;
+
+ const $reviewContainer = $(el).find('.lister-item-content');
+
+ const summary = clean($reviewContainer.children('a.title'));
+ const rating = clean($reviewContainer.children('.ipl-ratings-bar'));
+ const $by = $reviewContainer.find('.display-name-date .display-name-link a');
+ const by = {
+ name: clean($by),
+ link: $by.attr('href') ?? null,
+ };
+ const date = clean($reviewContainer.find('.display-name-date .review-date'));
+ const isSpoiler = $reviewContainer.children('.spoiler-warning').length > 0;
+ const reviewHtml = $reviewContainer.find('.content > .text').html();
+ const responses = clean($reviewContainer.find('.content > .actions').contents().first());
+ // .contents()
+ // .filter(function () {
+ // return this.nodeType === 3;
+ // })
+ // .map(function () {
+ // return $(this).text();
+ // })
+ // .get();
+
+ return {
+ summary,
+ reviewId,
+ rating,
+ by,
+ date,
+ isSpoiler,
+ reviewHtml,
+ responses,
+ };
+ })
+ .toArray();
+};
diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts
index c31260e..c74bd8f 100644
--- a/src/utils/helpers.ts
+++ b/src/utils/helpers.ts
@@ -81,18 +81,13 @@ export const AppError = class extends Error {
}
};
-export const cleanQueryStr = (
- entries: [string, string][],
- filterable = ['q', 's', 'exact', 'ttype']
-) => {
- let queryStr = '';
+export const cleanQueryStr = (record: Record, filterable: string[]) => {
+ const urlSearchParams = new URLSearchParams();
- entries.forEach(([key, val], i) => {
- if (!val || !filterable.includes(key)) return;
- queryStr += `${i > 0 ? '&' : ''}${key}=${val.trim()}`;
+ filterable.forEach(key => {
+ if (record[key]) urlSearchParams.append(key, record[key].trim());
});
-
- return queryStr;
+ return urlSearchParams.toString();
};
export const getResTitleTypeHeading = (