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
48 changes: 48 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# 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

# Playwright
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
198 changes: 185 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,193 @@
# GlobalWebIndex Engineering Challenge
# 🐱 Cat Lovers Gallery

## Exercise: CatLover
Welcome to **Cat Lovers Gallery** a fun and modern web app where you can:

Create a React application for cat lovers which is going to build upon thecatapi.com and will have 3 views.
The **first** view displays a list of 10 random cat images and a button to load more. Clicking on any of those images opens a modal view with the image and the information about the cat’s breed if available. This would be a link to the second view below - the breed detail. The modal should also contain a form to mark the image as your favourite (a part of the third view as well). Make sure you can copy-paste the URL of the modal and send it to your friends - they should see the same image as you can see.
- 🖼️ View cute cat pictures
- 📚 Learn about different cat breeds
- ❤️ Save your favorite images

The **second** view displays a list of cat breeds. Each breed opens a modal again with a list of cat images of that breed. Each of those images must be a link to the image detail from the previous point.
This app is built with **Next.js 15**, **Tailwind CSS**, and uses a colorful **neo-brutalism** design style.

The **third** view allows you do the following things:
---

- Display your favourite cats
- Remove an image from your favourites (use any UX option you like)
## ✨ Features

You can find the API documentation here: https://developers.thecatapi.com/
We give you a lot of freedom in technologies and ways of doing things. We only insist on you using React.js. Get creative as much as you want, we WILL appreciate it. You will not be evaluated based on how well you follow these instructions, but based on how sensible your solution will be. In case you are not able to implement something you would normally implement for time reasons, make it clear with a comment.
- 🖼️ A gallery showing many cat images as cards
- 🐾 Click an image to learn about the cat's breed (if available)
- 🧠 A page showing all available cat breeds
- 🐈 Click a breed to see only cats of that breed
- 🔍 Click images to open a modal with more breed info
- ❤️ View a list of your favorite cat images

## Submission
---

Once you have built your app, share your code in the mean suits you best
Good luck, potential colleague!
## 🛠️ How to run locally

### ⚠️❗ Get your API key from [https://thecatapi.com/](https://thecatapi.com/)

1. Clone the repo and open the folder

2. Install dependencies:

```bash
npm install
```

3. Setup your ENV variables at .env.local:

**Option 1: Automated Setup**

```bash
npm run setup-env
```

**Option 2: Manual Setup**
Create a `.env.local` file in the root directory with the following variables:

```
NEXT_PUBLIC_API_URL=https://api.thecatapi.com
NEXT_PUBLIC_API_VERSION=v1
NEXT_PUBLIC_API_KEY=your_api_key_here
```

4. Start the app:

```bash
npm run dev
```

5. Open [http://localhost:3000](http://localhost:3000)

> Or try the live version: [https://courageous-trifle-f9f9db.netlify.app/](https://courageous-trifle-f9f9db.netlify.app/)

---

## 🧪 How to run tests

> ⚠️ Before running the tests make sure that the local server is running localhost:3000

For headless running:

```bash
npm run tests:e2e
```

To run with the UI present:

```bash
npm run tests:e2e:ui
```

---

## 🗂️ Project Structure

All main code is inside the `src/` folder. We split the code into two main types:

### 1. General purpose code (not tied to business logic)

- **`components/`** – UI parts like buttons, modals, tabs
- **`hooks/`** – Utility hooks like `useDebounce`, `useLoadMore`
- **`utils/`** – Shared functions like HTTP request helpers

> These folders are **generic** and should **not import anything from the `app/` folder**. If you need to, consider moving the logic into the app instead.

---

### 2. Business logic (inside the `app/` folder)

This follows the **Next.js 15 app router structure**.

Each folder represents a page or feature, like a module.
Main folders:

- `breeds/` – Shows breed list and details
- `cats-gallery/` – Shows all cat images
- `favourites/` – Shows favorite images
- `user/` – Handles fake login
- `_api/` – Contains server actions

Each module can include:

- `_components/` – Components for that module's UI
- `_hooks/` – Hooks for that module's logic
- `_constants/` – Static values like API limits
- `@modal/` – Nested modal routes
- `default.tsx` – Used for parallel routes, more at [Parallel Routes](https://nextjs.org/docs/app/api-reference/file-conventions/parallel-routes)
- `page.tsx` – The server-rendered page built using the module's components

These modules **can share logic with each other** if needed.
For example, a hook in `breeds/` can be used in `cats-gallery/`.

---

## 🧠 Best practices

- Keep the components as closer to where the going to be consumed, if lets say you have a list component an a list item those components should stay in the same file.
- Avoid create multiple files per component, and avoid creating a tiny component that contain only a small part of the UI.
- Don't create abstractions that hide too much logic and make the code feel like magic. It's better to repeat some steps than to leave developers wondering how a component works or why something behaves a certain way.
- If you feel that you need to write a comment, please do it, but make sure that describe something that is not obvious from the code, for example a bussiness logic desission.

---

## 👤❤️ Pesonalized Favourites

We use a unique user ID to store favorites per user. Here's why this matters:

### 🎯 **Personalized Experience**

- Each user gets their own collection of favorite cat images
- Your favorites persist when you close and reopen your browser

### 🚫 **Without User IDs**

- No personalization, all users would share the same favorites list
- You might lose your favorite images when others add/remove items

### 💾 **How It Works**

- Your unique ID is stored in the `gwi-cats-__user` cookie
- The middleware automatically creates a new ID if you don't have one
- You can save your favorites by backing up your user ID from the cookie
- If you delete the cookie, you'll get a fresh start with a new user ID

You can try to delete your cookie, you will see that all you favourites is gone,
i recommend keeping `gwi-cats-__user` somewhere safe so you can reuse it in different
browser and still see the same favourites.

---

## 🎨 Design & Tech Decisions

- ⚙️ **Next.js 15**: For its file-based routing and React Server Components
- 💨 **Tailwind CSS**: Fast, flexible styling with a big community
- 🧱 Custom UI components: We build our own instead of using external libraries
- 🌈 **Neo-brutalism** design: A bold, fun theme that matches the playful nature of cats
Learn more here: [Neo-brutalism Design](https://blog.hubspot.com/website/neo-brutalism)

> We keep dependencies low we only install a package if it's truly needed.

---

## ⚡ Performance

- All pages are **server-rendered**
- **Next.js caching** is used to store API responses
- Pages load fast, especially when revisiting
- Perfect Lighthouse scores in:
- Performance
- Accessibility
- Best Practices
- SEO

🗼![lighthouse](https://private-user-images.githubusercontent.com/47026269/463845929-fa195d59-57b6-4e65-a565-b8ff181d3149.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NTIwMDIwNTYsIm5iZiI6MTc1MjAwMTc1NiwicGF0aCI6Ii80NzAyNjI2OS80NjM4NDU5MjktZmExOTVkNTktNTdiNi00ZTY1LWE1NjUtYjhmZjE4MWQzMTQ5LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTA3MDglMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwNzA4VDE5MDkxNlomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmV0dXJlPTRlNTcwMDY2MzBiYThlZTllZDY5NGRlY2FjYzlkODVjY2I1NjQxMWEwMWQ0NjMwODY2YmJkMDliNzFjNmQ0YzEmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.2tBsFB2tw8MEEUMPUObma53bINPdDeh4_seseaChUZg)

---

## 🧭 Roadmap

- 🔐 Add real user authentication to maintain the favourites across browsers
- 👍 Add a voting system for cat images
- 🐶 Add support for a dog API too!

---
127 changes: 127 additions & 0 deletions e2e/app.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { test, expect } from "@playwright/test";

test.describe("Cat Gallery", () => {
test("should navigate to cats gallery and interact with cat details", async ({
page,
}) => {
await page.goto("http://localhost:3000/cats-gallery");

await expect(page).toHaveURL(/.*cats-gallery/);

await page.getByRole("link", { name: /cat #/i }).first().click();

await expect(
page.getByRole("button", { name: "Favourite cat" })
).toBeVisible();

await page.getByRole("button", { name: "Favourite cat" }).click();

await expect(
page.getByRole("button", { name: "Unfavourite cat" })
).toBeVisible();

await page.getByRole("button", { name: "Unfavourite cat" }).click();

await expect(
page.getByRole("button", { name: "Favourite cat" })
).toBeVisible();

await page.getByRole("button", { name: "Close modal" }).click();

await expect(
page.getByRole("button", { name: "Close modal" })
).not.toBeVisible();
});
});

test.describe("Cat Breeds", () => {
test("should navigate to breeds and view breed details", async ({ page }) => {
await page.goto("http://localhost:3000/cats-gallery");

await page.getByRole("link", { name: "Cat Breeds" }).click();

await expect(page).toHaveURL(/.*breeds/);

await page.getByRole("link", { name: "Abyssinian Egypt" }).click();

await page.locator(".relative.w-full.h-48").first().click();

await expect(page.getByText("Weight:3 - 5kg")).toBeVisible();
await expect(page.getByText("Child Friendly:")).toBeVisible();
await expect(page.getByText("Health Issues:")).toBeVisible();
await expect(page.getByText("Energy Level:")).toBeVisible();
await expect(page.getByText("Intelligence:")).toBeVisible();
});

test("should navigate between cats-gallery and breeds and favorite a cat", async ({
page,
}) => {
await page.goto("http://localhost:3000/cats-gallery");

await page.getByRole("link", { name: "Cat Breeds" }).click();

await expect(page).toHaveURL(/.*breeds/);

await page.getByRole("link", { name: "Abyssinian" }).click();

await expect(
page.getByRole("heading", { name: "Abyssinian" })
).toBeVisible();

await page
.locator(
".grid.grid-cols-1.sm\\:grid-cols-2.md\\:grid-cols-3.gap-neo-lg.justify-items-center > a:nth-child(2)"
)
.click();

await expect(
page.getByRole("button", { name: "Favourite cat" })
).toBeVisible();

await page.getByRole("button", { name: "Favourite cat" }).click();

await expect(
page.getByRole("button", { name: "Unfavourite cat" })
).toBeVisible();

await page.getByRole("button", { name: "Close modal" }).click();

await expect(
page.getByRole("button", { name: "Close modal" })
).not.toBeVisible();
});
});

test.describe("Favourites", () => {
test("should manage favourites and navigate back to gallery", async ({
page,
}) => {
await page.goto("http://localhost:3000/cats-gallery");
await page.getByRole("link", { name: /cat #/i }).first().click();

await expect(
page.getByRole("button", { name: "Favourite cat" })
).toBeVisible();

await page.getByRole("button", { name: "Favourite cat" }).click();
await page.getByRole("button", { name: "Close modal" }).click();

await page.getByRole("link", { name: "Favourites" }).click();

await expect(page).toHaveURL(/.*favourites/);

await page.waitForSelector('button[aria-label="Unfavourite cat"]', {
state: "visible",
});

await page.getByRole("button", { name: "Unfavourite cat" }).click();

await expect(
page.getByRole("button", { name: "Browse Cats" })
).toBeVisible();

await page.getByRole("button", { name: "Browse Cats" }).click();

await expect(page).toHaveURL(/.*cats-gallery/);
});
});
Loading