From a75c13fa890e9c33585e9afd92983db47a0f136d Mon Sep 17 00:00:00 2001 From: guilherme goncalves Date: Mon, 2 Jun 2025 17:59:50 -0300 Subject: [PATCH 1/2] Merge --- .gitignore | 41 + README.md | 113 +- app/books/[id]/page.tsx | 15 + app/books/page.tsx | 11 + app/globals.css | 21 + app/layout.tsx | 22 + app/page.tsx | 10 + components/BookDetail.tsx | 51 + components/BookList.tsx | 68 + components/ReactQueryProvider.tsx | 8 + components/ReviewForm.tsx | 121 + components/ReviewList.tsx | 44 + eslint.config.mjs | 16 + hooks/useBooks.ts | 14 + package.json | 34 + pnpm-lock.yaml | 4367 +++++++++++++++++++++++++++++ pnpm-workspace.yaml | 3 + postcss.config.js | 6 + public/favicon.ico | 0 tailwind.config.js | 8 + tsconfig.json | 42 + utils/api.ts | 23 + 22 files changed, 4987 insertions(+), 51 deletions(-) create mode 100644 .gitignore create mode 100644 app/books/[id]/page.tsx create mode 100644 app/books/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 components/BookDetail.tsx create mode 100644 components/BookList.tsx create mode 100644 components/ReactQueryProvider.tsx create mode 100644 components/ReviewForm.tsx create mode 100644 components/ReviewList.tsx create mode 100644 eslint.config.mjs create mode 100644 hooks/useBooks.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 postcss.config.js create mode 100644 public/favicon.ico create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 utils/api.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/README.md b/README.md index 27d1c50..f5aa7e8 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,84 @@ -# Tech-Assessment – Book Reviews Platform +# Book Reviews Platform -**Goal** -Build a small “Book Reviews” platform (CRUD books + reviews, plus an endpoint that returns the top-rated books). +## Overview -| Stack (mandatory) | Why | -|-------------------|-----| -| NestJS + MongoDB | API, data layer & aggregation | -| Next.js (App Router) | UI & SSR | -| React Query | Data fetching / cache | -| Tailwind CSS | Styling | +This project is a "Book Reviews" platform that allows users to browse and review books. It is built using Next.js for the frontend and connects to a NestJS backend with a MongoDB database. -> **Time-box:** aim for **4-8 h** of focused work. -> When time is up, push what you have — unfinished is OK, but document what’s missing. +## Tech Stack ---- +- **Frontend**: Next.js, React, Tailwind CSS +- **Backend**: NestJS, MongoDB +- **Data Fetching**: React Query -## 1. What you must deliver +## Features -| Area | Minimum requirements | -|------|----------------------| -| **Backend** | *Connect to MongoDB* via env var
*Models*: `Book`, `Review` (rating 1-5)
*CRUD* endpoints for both entities (`/books`, `/books/:id/reviews`)
*Aggregation*: `GET /books/top?limit=10` returns avgRating + reviewCount, sorted desc
*Tests*: at least **one** e2e test hitting `/books/top` | -| **Frontend** | `/books` page listing the top books (uses React Query)
Book detail page showing reviews and a form to add a review (optimistic update welcome)
Responsive UI with Tailwind | -| **DX / Ops** | Clear local-dev instructions (README or Makefile)
`.env.example` with all needed vars
Lint + format commands
(Optional) Docker setup | +- CRUD functionality for books and reviews +- Display of top-rated books +- Responsive design using Tailwind CSS +- Optimistic UI updates for adding reviews ---- +## Getting Started -## 2. Local setup expected by reviewers +### Prerequisites -```bash -pnpm install # monorepo or multiple projects — you choose -pnpm dev # should start both backend and frontend -# backend on :3001, frontend on :3000 is a common pattern -``` +- Node.js (version 14 or higher) +- MongoDB (local or cloud instance) + +### Installation + +1. Clone the repository: + + ``` + git clone + cd book-reviews-frontend + ``` -If you rely on Docker (e.g. docker compose up mongo), document it. +2. Install dependencies: -⸻ + ``` + pnpm install + ``` -## 3. Submission guidelines -1. Fork this repo, build on main. -2. Open a pull request to your own fork when finished. In the PR description include: - - (i) What is done / not done, - - (ii) How to run tests and - - (iii) Any trade-offs or shortcuts -3. Do not open a PR against the original repo. +3. Create a `.env` file based on the `.env.example` file and fill in the necessary environment variables. -⸻ +### Running the Application -## 4. Evaluation rubric +To start the development server, run: + +``` +pnpm dev +``` -Criterion Weight +The frontend will be available at `http://localhost:3000`. -- Correctness & tests 30 % -- Code quality / structure 20 % -- Data modelling & validation 15 % -- Aggregation query efficiency 10 % -- Frontend UX & accessibility 15 % -- Documentation 10 % +### Directory Structure + +- `app/`: Contains the main application files and pages. +- `components/`: Contains reusable React components. +- `hooks/`: Custom hooks for data fetching. +- `utils/`: Utility functions for API calls. +- `public/`: Static assets like images and icons. +- `tailwind.config.js`: Configuration for Tailwind CSS. +- `postcss.config.js`: Configuration for PostCSS. +- `tsconfig.json`: TypeScript configuration. +- `package.json`: Project dependencies and scripts. + +## Running Tests + +To run tests, use: + +``` +pnpm test +``` +## Documentation -⸻ +For detailed API documentation, refer to the backend repository. -## 5. Constraints & tips +## Contributing -- TypeScript everywhere. -- Keep third-party libs minimal (testing & dev-tools are fine). -- Commit early & often — we read history. -- Feel free to use dev-containers / Codespaces; just explain how. +Feel free to submit issues or pull requests. Please ensure your code adheres to the project's coding standards. -⸻ +## License -Good luck 🚀 +This project is licensed under the MIT License. diff --git a/app/books/[id]/page.tsx b/app/books/[id]/page.tsx new file mode 100644 index 0000000..a633367 --- /dev/null +++ b/app/books/[id]/page.tsx @@ -0,0 +1,15 @@ +'use client'; +import React from 'react'; +import BookDetail from '../../../components/BookDetail'; +import { useParams } from 'next/navigation'; + +export default function BookDetailPage() { + const params = useParams() as { id: string }; + const bookId = params?.id; + + return ( +
+ +
+ ); +} diff --git a/app/books/page.tsx b/app/books/page.tsx new file mode 100644 index 0000000..93a9c64 --- /dev/null +++ b/app/books/page.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import BookList from '../../components/BookList'; + +export default function BooksPage() { + return ( +
+

Top Books

+ +
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..baeeaaf --- /dev/null +++ b/app/globals.css @@ -0,0 +1,21 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + background: var(--background); + color: var(--foreground); + font-family: system-ui, sans-serif; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..a4889f7 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from 'next'; +import './globals.css'; +import ReactQueryProvider from '../components/ReactQueryProvider'; + +export const metadata: Metadata = { + title: 'Create Next App', + description: 'Generated by create next app', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..b1d4d0b --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,10 @@ +import BookList from '../components/BookList'; + +export default function Home() { + return ( +
+

Top Books

+ +
+ ); +} diff --git a/components/BookDetail.tsx b/components/BookDetail.tsx new file mode 100644 index 0000000..3456d57 --- /dev/null +++ b/components/BookDetail.tsx @@ -0,0 +1,51 @@ +'use client'; + +import React from 'react'; +import { useQuery } from 'react-query'; +import { fetchBookById, fetchReviewsByBookId } from '../utils/api'; +import ReviewList from './ReviewList'; +import ReviewForm from './ReviewForm'; + +interface BookDetailProps { + bookId: string; +} + +const BookDetail = ({ bookId }: BookDetailProps) => { + const { + data: book, + isLoading: isBookLoading, + error: bookError, + } = useQuery(['book', bookId], () => fetchBookById(bookId)); + const { + data: reviewsData, + isLoading: isReviewsLoading, + error: reviewsError, + } = useQuery(['reviews', bookId], () => fetchReviewsByBookId(bookId)); + + if (isBookLoading || isReviewsLoading) return
Loading...
; + if (bookError || reviewsError) return
Error loading book details
; + + const avgRating = reviewsData?.avgRating ?? reviewsData?.avg_rating ?? 'N/A'; + const reviews = reviewsData?.reviews ?? []; + + return ( +
+

{book?.title}

+

{book?.description}

+
+ + + {Number(avgRating).toFixed(2)} / 5.0 + + + ({reviews.length} review{reviews.length !== 1 && 's'}) + +
+

Reviews

+ + +
+ ); +}; + +export default BookDetail; diff --git a/components/BookList.tsx b/components/BookList.tsx new file mode 100644 index 0000000..94a78d4 --- /dev/null +++ b/components/BookList.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React from 'react'; +import { useQuery } from 'react-query'; +import { fetchBooks } from '../utils/api'; + +const BookList = () => { + const { data: books, isLoading, error } = useQuery('books', fetchBooks); + + if (isLoading) { + return
Loading top books...
; + } + + if (error) { + return ( +
+ Error loading books. Please try again later. +
+ ); + } + + if (!Array.isArray(books) || books.length === 0) { + return
No books found.
; + } + + return ( +
+ {books.map((book: any, idx: number) => { + const bookId = book.id || book._id || idx; + return ( +
+
+ #{idx + 1} +
+

{book.title}

+ +
+ + + {Number(book.avgRating).toFixed(2) ?? 'N/A'} / 5.0 + +
+ +

+ {book.reviews?.length ?? 0} review + {(book.reviews?.length ?? 0) !== 1 && 's'} +

+ + + 📖 View Details + +
+ ); + })} +
+ ); +}; + +export default BookList; diff --git a/components/ReactQueryProvider.tsx b/components/ReactQueryProvider.tsx new file mode 100644 index 0000000..6eacfbb --- /dev/null +++ b/components/ReactQueryProvider.tsx @@ -0,0 +1,8 @@ +'use client'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import React, { ReactNode, useState } from 'react'; + +export default function ReactQueryProvider({ children }: { children: ReactNode }) { + const [queryClient] = useState(() => new QueryClient()); + return {children}; +} diff --git a/components/ReviewForm.tsx b/components/ReviewForm.tsx new file mode 100644 index 0000000..96d46a6 --- /dev/null +++ b/components/ReviewForm.tsx @@ -0,0 +1,121 @@ +'use client'; + +import React, { useState } from 'react'; +import { useMutation, useQueryClient } from 'react-query'; +import { submitReview } from '../utils/api'; + +interface ReviewFormProps { + bookId: string; +} + +interface ReviewInput { + rating: number; + comment: string; + book: string; +} + +const ReviewForm = ({ bookId }: ReviewFormProps) => { + const [rating, setRating] = useState(1); + const [comment, setComment] = useState(''); + const [formError, setFormError] = useState(null); + const queryClient = useQueryClient(); + + const mutation = useMutation((input: ReviewInput) => submitReview(bookId, input), { + onMutate: async (input) => { + setFormError(null); + const prevReviews = queryClient.getQueryData(['reviews', bookId]); + queryClient.setQueryData(['reviews', bookId], (old: any) => { + const optimistic = { + ...input, + author: 'You', + rating: input.rating, + _id: 'optimistic-' + Date.now(), + }; + return { + ...old, + reviews: [optimistic, ...(old?.reviews || [])], + }; + }); + return { prevReviews }; + }, + onError: (err, _input, context) => { + setFormError('Failed to submit review. Please try again.'); + if (context?.prevReviews) { + queryClient.setQueryData(['reviews', bookId], context.prevReviews); + } + }, + onSettled: () => { + queryClient.invalidateQueries(['book', bookId]); + queryClient.invalidateQueries(['reviews', bookId]); + queryClient.invalidateQueries('books'); + setRating(1); + setComment(''); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + if (!comment.trim()) { + setFormError('Comment is required.'); + return; + } + if (rating < 1 || rating > 5) { + setFormError('Rating must be between 1 and 5.'); + return; + } + mutation.mutate({ rating, comment, book: bookId }); + }; + + return ( +
+

Add a Review

+ {formError && ( +
+ {formError} +
+ )} +
+ + +
+
+ +