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 (
+
+ {books.map((book) => (
+ -
+
+
+ ))}
+
+ );
+}
+
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) => (
- -
-
-
- ))}
-
+
);