Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
113 changes: 62 additions & 51 deletions README.md
Original file line number Diff line number Diff line change
@@ -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<br>*Models*: `Book`, `Review` (rating 1-5)<br>*CRUD* endpoints for both entities (`/books`, `/books/:id/reviews`)<br>*Aggregation*: `GET /books/top?limit=10` returns avgRating + reviewCount, sorted desc<br>*Tests*: at least **one** e2e test hitting `/books/top` |
| **Frontend** | `/books` page listing the top books (uses React Query)<br>Book detail page showing reviews and a form to add a review (optimistic update welcome)<br>Responsive UI with Tailwind |
| **DX / Ops** | Clear local-dev instructions (README or Makefile)<br>`.env.example` with all needed vars<br>Lint + format commands<br>(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 <your-repo-url>
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.
15 changes: 15 additions & 0 deletions app/books/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="container mx-auto py-8">
<BookDetail bookId={bookId} />
</div>
);
}
11 changes: 11 additions & 0 deletions app/books/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react';
import BookList from '../../components/BookList';

export default function BooksPage() {
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">Top Books</h1>
<BookList />
</div>
);
}
21 changes: 21 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
@@ -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;
}
22 changes: 22 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="en">
<body className={`antialiased`}>
<ReactQueryProvider>{children}</ReactQueryProvider>
</body>
</html>
);
}
10 changes: 10 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import BookList from '../components/BookList';

export default function Home() {
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">Top Books</h1>
<BookList />
</div>
);
}
51 changes: 51 additions & 0 deletions components/BookDetail.tsx
Original file line number Diff line number Diff line change
@@ -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 <div>Loading...</div>;
if (bookError || reviewsError) return <div>Error loading book details</div>;

const avgRating = reviewsData?.avgRating ?? reviewsData?.avg_rating ?? 'N/A';
const reviews = reviewsData?.reviews ?? [];

return (
<div className="p-4">
<h1 className="text-2xl font-bold">{book?.title}</h1>
<p className="text-lg">{book?.description}</p>
<div className="flex items-center gap-2 mt-2 mb-4">
<span className="text-yellow-500 text-lg">★</span>
<span className="text-zinc-700 dark:text-zinc-300 font-medium">
{Number(avgRating).toFixed(2)} / 5.0
</span>
<span className="text-sm text-zinc-500 ml-2">
({reviews.length} review{reviews.length !== 1 && 's'})
</span>
</div>
<h2 className="text-xl mt-4">Reviews</h2>
<ReviewForm bookId={bookId} />
<ReviewList reviews={reviews} />
</div>
);
};

export default BookDetail;
68 changes: 68 additions & 0 deletions components/BookList.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="text-center py-8 text-lg animate-pulse">Loading top books...</div>;
}

if (error) {
return (
<div className="text-center py-8 text-red-500">
Error loading books. Please try again later.
</div>
);
}

if (!Array.isArray(books) || books.length === 0) {
return <div className="text-center py-8 text-zinc-500">No books found.</div>;
}

return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 p-6">
{books.map((book: any, idx: number) => {
const bookId = book.id || book._id || idx;
return (
<div
key={bookId}
className="relative bg-gradient-to-br from-white via-zinc-50 to-zinc-100 dark:from-zinc-900 dark:via-zinc-800 dark:to-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-3xl p-6 shadow-lg hover:shadow-2xl transition-shadow duration-300 group"
tabIndex={0}
aria-label={`Book: ${book.title}`}
>
<div className="absolute top-4 right-4 text-sm text-zinc-400 group-hover:text-blue-500 transition-colors">
#{idx + 1}
</div>
<h2 className="text-2xl font-bold text-zinc-800 dark:text-white mb-3">{book.title}</h2>

<div className="flex items-center gap-2 mb-2">
<span className="text-yellow-500 text-lg">★</span>
<span className="text-zinc-700 dark:text-zinc-300 font-medium">
{Number(book.avgRating).toFixed(2) ?? 'N/A'} / 5.0
</span>
</div>

<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-4">
{book.reviews?.length ?? 0} review
{(book.reviews?.length ?? 0) !== 1 && 's'}
</p>

<a
href={`/books/${bookId}`}
className="inline-block w-full text-center mt-auto bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-xl transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label={`View details for ${book.title}`}
>
📖 View Details
</a>
</div>
);
})}
</div>
);
};

export default BookList;
8 changes: 8 additions & 0 deletions components/ReactQueryProvider.tsx
Original file line number Diff line number Diff line change
@@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
Loading