diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..259de13 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,25 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e59c10b --- /dev/null +++ b/.gitignore @@ -0,0 +1,100 @@ +<<<<<<< HEAD +# 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 +======= +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +>>>>>>> back/master diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..dcb7279 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/README.md b/README.md index 27d1c50..dc29bc0 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,228 @@ -# Tech-Assessment – Book Reviews Platform +<<<<<<< HEAD +# 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 + +- Node.js (version 14 or higher) +- MongoDB (local or cloud instance) + +### Installation + +1. Clone the repository: + + ``` + git clone + cd book-reviews-frontend + ``` + +2. Install dependencies: + + ``` + pnpm install + ``` + +3. Create a `.env` file based on the `.env.example` file and fill in the necessary environment variables. + +### Running the Application + +To start the development server, run: + +``` +pnpm dev +``` + +The frontend will be available at `http://localhost:3000`. + +### 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. + +## Contributing + +Feel free to submit issues or pull requests. Please ensure your code adheres to the project's coding standards. + +## License + +This project is licensed under the MIT License. +======= +

+ Nest Logo +

+ +[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 +[circleci-url]: https://circleci.com/gh/nestjs/nest + +

A progressive Node.js framework for building efficient and scalable server-side applications.

+

+NPM Version +Package License +NPM Downloads +CircleCI +Coverage +Discord +Backers on Open Collective +Sponsors on Open Collective + Donate us + Support us + Follow us on Twitter +

+ + +## Description + +[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Project setup + +```bash +$ pnpm install +``` + +## Compile and run the project + +```bash +# development +$ pnpm run start + +# watch mode +$ pnpm run start:dev + +# production mode +$ pnpm run start:prod +``` + +## Run tests + +```bash +# unit tests +$ pnpm run test + +# e2e tests +$ pnpm run test:e2e + +# test coverage +$ pnpm run test:cov +``` + +## Deployment + +When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. + +If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps: ```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 +$ pnpm install -g mau +$ mau deploy ``` -If you rely on Docker (e.g. docker compose up mongo), document it. +With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. + +## Resources + +Check out a few resources that may come in handy when working with NestJS: + +- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. +- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). +- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). +- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. +- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). +- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). +- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). +- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). + +## Support -⸻ +Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). -## 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. +## Stay in touch -⸻ +- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) -## 4. Evaluation rubric +## License -Criterion Weight +Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). -- Correctness & tests 30 % -- Code quality / structure 20 % -- Data modelling & validation 15 % -- Aggregation query efficiency 10 % -- Frontend UX & accessibility 15 % -- Documentation 10 % +# Book Reviews Backend +## Local Development -⸻ +### Prerequisites -## 5. Constraints & tips +- Node.js (v18+ recommended) +- pnpm +- MongoDB (local or Docker) -- 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. +### Setup + +1. Install dependencies: + ```powershell + pnpm install + ``` +2. Create a `.env` file based on `.env.example` and set your MongoDB URI. +3. Start the backend server: + ```powershell + pnpm run start:dev + ``` + The server will run on http://localhost:3001 by default (set PORT in .env to change). + +### Scripts + +- `pnpm run lint` – Lint code +- `pnpm run format` – Format code +- `pnpm run test` – Run all tests +- `pnpm run test:e2e` – Run e2e tests + +### Docker (optional) + +To run MongoDB with Docker: + +```powershell +docker run --name mongo -p 27017:27017 -d mongo +``` -⸻ +## Environment Variables -Good luck 🚀 +See `.env.example` for required variables. +>>>>>>> back/master 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} +
+ )} +
+ + +
+
+ +