diff --git a/frontend/app/hooks/useSuggestedBooks.ts b/frontend/app/hooks/useSuggestedBooks.ts new file mode 100644 index 00000000..4207d514 --- /dev/null +++ b/frontend/app/hooks/useSuggestedBooks.ts @@ -0,0 +1,31 @@ +import type { BookFilters } from "~/api/types"; +import { useSearchBooks } from "~/hooks/useSearchBooks"; +import { BOOK_PAGE_LIMIT } from "~/lib/constants"; + +const MIN_SUGGESTION_RATINGS = 10_000; + +function useSuggestedBooks({ + query, + filters, + enabled, +}: { + query: string; + filters: BookFilters; + enabled: boolean; +}) { + const keptFilters = query ? filters : {}; + + return useSearchBooks({ + filters: { + ...keptFilters, + minRatings: Math.max(keptFilters.minRatings ?? 0, MIN_SUGGESTION_RATINGS), + }, + title: "", + limit: BOOK_PAGE_LIMIT, + offset: 0, + sort: "rating_desc", + enabled, + }); +} + +export { useSuggestedBooks }; diff --git a/frontend/app/routes/book/selectedBook.tsx b/frontend/app/routes/book/selectedBook.tsx index c1b79206..6b5ab169 100644 --- a/frontend/app/routes/book/selectedBook.tsx +++ b/frontend/app/routes/book/selectedBook.tsx @@ -19,6 +19,7 @@ import { useSearchBooks } from "~/hooks/useSearchBooks"; import { useBookSearchParams } from "~/hooks/useBookSearchParams"; import { cn } from "~/lib/utils"; import { useFavoriteBooks } from "~/hooks/useFavoriteBooks"; +import { useSuggestedBooks } from "~/hooks/useSuggestedBooks"; import { BookRating } from "~/components/BookRating/BookRating"; import { BOOK_PAGE_LIMIT } from "~/lib/constants"; @@ -37,7 +38,7 @@ export default function SelectedBookPage({ params }: Route.ComponentProps) { limit: BOOK_PAGE_LIMIT, offset: (page - 1) * BOOK_PAGE_LIMIT, sort, - enabled: origin !== "favorites", + enabled: origin !== "favorites" && origin !== "suggestions", }); const { books: favoriteBooks } = useFavoriteBooks({ @@ -45,7 +46,18 @@ export default function SelectedBookPage({ params }: Route.ComponentProps) { enabled: origin === "favorites", }); - const books = origin === "favorites" ? favoriteBooks : searchBooks; + const { books: suggestedBooks } = useSuggestedBooks({ + query, + filters, + enabled: origin === "suggestions", + }); + + const books = + origin === "favorites" + ? favoriteBooks + : origin === "suggestions" + ? suggestedBooks + : searchBooks; if (loading) { return ( diff --git a/frontend/app/routes/books.test.tsx b/frontend/app/routes/books.test.tsx index 453c82c8..52b17c0f 100644 --- a/frontend/app/routes/books.test.tsx +++ b/frontend/app/routes/books.test.tsx @@ -79,7 +79,20 @@ describe("BooksPage", () => { it("tells the user when no books match the search", async () => { renderRoutes(routes, { initialEntries: ["/books?q=zzzz"], - mocks: [searchBooksMock({ title: "zzzz", limit: 12, offset: 0 }, [])], + mocks: [ + searchBooksMock({ title: "zzzz", limit: 12, offset: 0 }, []), + searchBooksMock( + { + title: "", + minRatings: 10000, + sortBy: "RATING", + sortDirection: "DESC", + limit: 12, + offset: 0, + }, + [], + ), + ], }); expect( @@ -88,6 +101,131 @@ describe("BooksPage", () => { expect(screen.getByText("0 results")).toBeInTheDocument(); }); + it("suggests top-rated books with the same filters when the search has no results", async () => { + renderRoutes(routes, { + initialEntries: ["/books?q=zzzz&genres=Fantasy"], + mocks: [ + searchBooksMock( + { title: "zzzz", genres: ["Fantasy"], limit: 12, offset: 0 }, + [], + ), + searchBooksMock({ + title: "", + genres: ["Fantasy"], + minRatings: 10000, + sortBy: "RATING", + sortDirection: "DESC", + limit: 12, + offset: 0, + }), + ], + }); + + expect( + await screen.findByRole("heading", { + name: "Top-rated books matching your filters", + }), + ).toBeInTheDocument(); + expect( + screen.getByText("No books found for this search"), + ).toBeInTheDocument(); + expect(await findBookLinks()).toHaveLength(3); + }); + + it("suggests top-rated books without filters when the filters have no results", async () => { + renderRoutes(routes, { + initialEntries: ["/books?genres=Fantasy&maxPages=1"], + mocks: [ + searchBooksMock( + { + title: "", + genres: ["Fantasy"], + maxPages: 1, + limit: 12, + offset: 0, + }, + [], + ), + searchBooksMock({ + title: "", + minRatings: 10000, + sortBy: "RATING", + sortDirection: "DESC", + limit: 12, + offset: 0, + }), + ], + }); + + expect( + await screen.findByRole("heading", { name: "Top-rated books" }), + ).toBeInTheDocument(); + expect(await findBookLinks()).toHaveLength(3); + }); + + it("keeps the user's minimum number of ratings in the suggestions when it is higher", async () => { + renderRoutes(routes, { + initialEntries: ["/books?q=zzzz&minRatings=50000"], + mocks: [ + searchBooksMock( + { title: "zzzz", minRatings: 50000, limit: 12, offset: 0 }, + [], + ), + searchBooksMock({ + title: "", + minRatings: 50000, + sortBy: "RATING", + sortDirection: "DESC", + limit: 12, + offset: 0, + }), + ], + }); + + expect(await findBookLinks()).toHaveLength(3); + }); + + it("browses through the suggestions on a suggested book's details page", async () => { + const user = userEvent.setup(); + renderRoutes(routes, { + initialEntries: ["/books?q=zzzz"], + // The suggestions are reused from Apollo's cache on the details page. + mocks: [ + searchBooksMock({ title: "zzzz", limit: 12, offset: 0 }, []), + searchBooksMock({ + title: "", + minRatings: 10000, + sortBy: "RATING", + sortDirection: "DESC", + limit: 12, + offset: 0, + }), + bookMock(2, { + ...apiBook, + id: 2, + title: "Before They Are Hanged", + description: "The second book in The First Law trilogy.", + }), + ], + }); + + await user.click( + await screen.findByRole("link", { + name: "View details for Before They Are Hanged", + }), + ); + + expect( + await screen.findByText("The second book in The First Law trilogy."), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Go to previous book" }), + ).not.toHaveAttribute("aria-disabled", "true"); + expect( + screen.getByRole("link", { name: "Go to next book" }), + ).not.toHaveAttribute("aria-disabled", "true"); + }); + it("shows an error message when the API request fails", async () => { renderRoutes(routes, { initialEntries: ["/books"], diff --git a/frontend/app/routes/books.tsx b/frontend/app/routes/books.tsx index 80732f33..942c83c9 100644 --- a/frontend/app/routes/books.tsx +++ b/frontend/app/routes/books.tsx @@ -1,9 +1,11 @@ import { BookPagination } from "~/components/BookPagination/BookPagination"; -import { useSearchBooks } from "~/hooks/useSearchBooks"; +import { useSearchBooks, type SearchBook } from "~/hooks/useSearchBooks"; import { SearchBar } from "~/components/books/SearchBar/SearchBar"; import { BookCard } from "~/components/BookCard/BookCard"; import { SortSelect } from "~/components/books/SortSelect/SortSelect"; import { useBookSearchParams } from "~/hooks/useBookSearchParams"; +import { useSuggestedBooks } from "~/hooks/useSuggestedBooks"; +import { useSearchParams } from "react-router"; import { BOOK_PAGE_LIMIT } from "~/lib/constants"; export function meta() { @@ -13,7 +15,26 @@ export function meta() { ]; } +function BookList({ + books, + searchListParams, +}: { + books: SearchBook[]; + searchListParams?: string; +}) { + return ( + + ); +} + export default function BooksPage() { + const [searchParams] = useSearchParams(); const { query, page, filters, sort } = useBookSearchParams(); const { books, totalCount, loading, error } = useSearchBooks({ @@ -24,6 +45,21 @@ export default function BooksPage() { sort, }); + const hasFilters = Object.values(filters).some( + (value) => value !== undefined, + ); + + const { books: suggestions } = useSuggestedBooks({ + query, + filters, + enabled: !loading && !error && totalCount === 0, + }); + + // If we get no results, we set this so the book details page uses the suggestions + // and the previous/next buttons work as expected. + const suggestionParams = new URLSearchParams(searchParams); + suggestionParams.set("origin", "suggestions"); + if (loading) { return (
@@ -53,12 +89,43 @@ export default function BooksPage() { } if (!books || books.length === 0) { + if (suggestions && suggestions.length > 0) { + return ( +
+ +
+

+ No books found for this search +

+

+ In the meantime, here are some popular books you might like +

+

+ {query && hasFilters + ? "Top-rated books matching your filters" + : "Top-rated books"} +

+ +
+
+ ); + } + return (

No books found for this search

@@ -71,13 +138,7 @@ export default function BooksPage() { -
    - {books.map((book) => ( -
  • - -
  • - ))} -
+
);