Skip to content
Merged
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
207 changes: 207 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# Epicure

A restaurant discovery and ordering platform — browse restaurants and chefs, filter by cuisine/price/rating/distance, view dish menus, and check out with a live order history.

Built as an Nx monorepo: a Next.js frontend, a NestJS API layer, and a Strapi CMS backing content and orders.

<img src="apps/frontend/public/screenshots/hero.jpg" width="800" alt="Epicure homepage" />

## Table of contents

- [Features](#features)
- [Screenshots](#screenshots)
- [Tech stack](#tech-stack)
- [Architecture](#architecture)
- [Monorepo structure](#monorepo-structure)
- [Getting started](#getting-started)
- [Environment variables](#environment-variables)
- [Available scripts](#available-scripts)
- [Testing & CI](#testing--ci)
- [Contributing](#contributing)

## Features

- **Restaurant discovery** — browse by tab (All / Popular / New / Open Now / Map), filter by price, rating, cuisine, and distance from your location
- **Map view** — restaurant markers on an interactive map (MapTiler + Leaflet), with a user-location dot and recenter button
- **Search** — live-filtered search across restaurants, cuisines, and chefs from the header and homepage hero
- **Restaurant & dish detail** — meal-time tabs (Breakfast/Lunch/Dinner), dish type icons (spicy/vegan/vegetarian), signature dishes
- **Chefs** — chef profiles, "Chef of the Week" highlight, modal bios, deep-linking from search results
- **Cart & checkout** — per-user cart (survives login/logout with conflict resolution on merge), delivery + payment form, order summary, success confirmation
- **Order history** — past orders with re-order and order-summary views
- **Auth** — register/login, JWT-based session, auto-logout on token expiry
- **Infinite scroll** — restaurants, chefs, and order history all paginate as you scroll (see [`useInfiniteScroll`](apps/frontend/src/hooks/useInfiniteScroll.ts))

## Screenshots

**Homepage** — hero search, popular restaurants, chef of the week, signature dishes

<img src="apps/frontend/public/screenshots/chef_week.jpg" width="800" alt="Homepage: icons legend and chef of the week" />
<img src="apps/frontend/public/screenshots/sign_dish.jpg" width="800" alt="Homepage: signature dishes" />

**Restaurants** — tabs, filters (price/rating/cuisine/distance), and map view

<img src="apps/frontend/public/screenshots/resturants.jpg" width="800" alt="Restaurants list with filters" />
<img src="apps/frontend/public/screenshots/map_view.jpg" width="800" alt="Restaurants map view with distance filter" />

**Restaurant detail** — meal-time tabs and dish customization

<img src="apps/frontend/public/screenshots/resturant.jpg" width="800" alt="Restaurant detail page" />
<img src="apps/frontend/public/screenshots/dish_modal.jpg" width="800" alt="Dish customization modal" />

**Chefs** — grid with bio modal

<img src="apps/frontend/public/screenshots/chef_modal.jpg" width="800" alt="Chefs grid with chef bio modal open" />

**Checkout & order history**

<img src="apps/frontend/public/screenshots/checkout.jpg" width="800" alt="Checkout page with delivery, payment, and order summary" />
<img src="apps/frontend/public/screenshots/order_history.jpg" width="800" alt="Order history page" />

**Mobile**

<table>
<tr>
<td><img src="apps/frontend/public/screenshots/mobile_home.jpg" width="280" alt="Mobile homepage" /></td>
<td><img src="apps/frontend/public/screenshots/mobile_dish.jpg" width="280" alt="Mobile dish customization modal" /></td>
</tr>
</table>

## Tech stack

| Layer | Tech |
|---|---|
| Frontend | Next.js 16 (App Router, Turbopack), React 19, SCSS (SMACSS) |
| Backend | NestJS 11 |
| CMS | Strapi 5 (SQLite) |
| Shared | TypeScript, Nx 22 monorepo, npm workspaces |
| Maps | MapTiler + Leaflet / react-leaflet |
| Testing | Jest, React Testing Library, Playwright (e2e) |

## Architecture

```
Frontend (Next.js, :3000)
│ fetch/post
▼
Backend (NestJS, :3333)
│ proxies + transforms
▼
Strapi CMS (:1337)
```

The frontend never calls Strapi directly — the NestJS backend acts as a proxy layer: it fetches from Strapi, reshapes the responses into clean REST endpoints, and is the only service that holds the Strapi admin token used for order read/write.

**Backend endpoints:**

- `GET /api/restaurants`, `GET /api/restaurants/:id`
- `GET /api/chefs`, `GET /api/chefs/:id`
- `GET /api/dishes`, `GET /api/dishes/:id`
- `POST /api/auth/login`, `POST /api/auth/register`
- `POST /api/orders`, `GET /api/orders`

## Monorepo structure

```
apps/
frontend/ Next.js 16 app (React, Turbopack, App Router) — port 3000
frontend-e2e/ Playwright e2e tests
backend/ NestJS 11 REST API — port 3333
backend-e2e/ Jest integration tests
libs/
shared-types/ TypeScript interfaces shared between frontend & backend
ui-components/ React component library (StarRating, RestaurantCard, DishCard, ChefCard, ...)
utils/ Shared utility functions
strapi/ Strapi 5 CMS — port 1337 (separate ecosystem, own node_modules)
```

## Getting started

### Prerequisites

- Node.js **24** (the lockfile is generated with npm 11)
- npm

### Install

```bash
npm install
cd strapi && npm install && cd ..
```

### Configure environment

Copy the Strapi env template and fill in real secrets:

```bash
cp strapi/.env.example strapi/.env
```

Create `apps/backend/.env` with a Strapi admin API token (generate one in the Strapi admin panel after first boot — see [Environment variables](#environment-variables)):

```
STRAPI_ADMIN_TOKEN=your-strapi-full-access-token
```

Create `apps/frontend/.env.local` with a [MapTiler](https://www.maptiler.com/) API key (needed for the restaurants map view):

```
NEXT_PUBLIC_MAPTILER_KEY=your-maptiler-key
```

### Run

```bash
npm run dev:all # frontend + backend + strapi together
```

Or run just the app (frontend + backend) if Strapi is already running elsewhere:

```bash
npm run dev
```

Then visit:

- Frontend — http://localhost:3000
- Backend API — http://localhost:3333
- Strapi admin — http://localhost:1337/admin

On first boot, create a Strapi admin user, add some restaurants/chefs/dishes content, and generate a **Full access** API token under *Settings → API Tokens* — paste it into `apps/backend/.env` as `STRAPI_ADMIN_TOKEN`.

## Environment variables

| Variable | Where | Default | Purpose |
|---|---|---|---|
| `STRAPI_URL` | backend | `http://localhost:1337` | Strapi base URL the backend proxies to |
| `STRAPI_ADMIN_TOKEN` | `apps/backend/.env` (gitignored) | — | Strapi Full-access token; required for order read/write. Must be regenerated after any Strapi DB reset |
| `PORT` | backend | `3333` | Backend listen port |
| `NEXT_PUBLIC_BACKEND_URL` | frontend | `http://localhost:3333` | NestJS base URL used by the frontend |
| `NEXT_PUBLIC_STRAPI_URL` | frontend | `http://localhost:1337` | Base URL for building Strapi image URLs |
| `NEXT_PUBLIC_MAPTILER_KEY` | `apps/frontend/.env.local` (gitignored) | — | MapTiler API key for map tiles |

## Available scripts

All tasks run through [Nx](https://nx.dev) — prefer these over calling the underlying tooling directly.

```bash
npx nx run-many -t lint test build --exclude strapi # what CI runs
npx nx run @org/frontend:dev # frontend only
npx nx run @org/backend:serve # backend only
npx nx e2e @org/frontend-e2e # Playwright e2e
npx nx affected -t test # only what changed
```

## Testing & CI

`.github/workflows/ci.yml` runs on every push to `main` and every PR:

1. `npm ci` (Node 24)
2. `npx nx run-many -t lint test build --exclude strapi`

Strapi is excluded from CI since it's a separate ecosystem with its own dependency tree.

## Contributing

- `main` is protected — all changes go through a PR and need approval from a [CODEOWNERS](.github/CODEOWNERS) reviewer
- Nx Cloud is intentionally disabled for this repo
- See `CLAUDE.md` for detailed conventions (SCSS architecture, component patterns, known gotchas) if you're using an AI coding assistant on this repo
Binary file added apps/frontend/public/screenshots/checkout.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/frontend/public/screenshots/chef_week.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/frontend/public/screenshots/dish_modal.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/frontend/public/screenshots/hero.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/frontend/public/screenshots/map_view.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 15 additions & 5 deletions apps/frontend/src/app/orders/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { fetchOrders } from '../../lib/api';
import { TEXT } from '../../lib/text';
import { OrderRow } from '../../components/orders/OrderRow';
import { OrderSummaryModal } from '../../components/orders/OrderSummaryModal';
import { useInfiniteScroll } from '../../hooks/useInfiniteScroll';

const PAGE_SIZE = 6;

export default function OrdersPage() {
const router = useRouter();
Expand All @@ -32,6 +35,8 @@ export default function OrdersPage() {
return () => { active = false; };
}, [user, router]);

const { visibleItems: visibleOrders, hasMore, sentinelRef } = useInfiniteScroll(orders, PAGE_SIZE);

// Return null until mounted so server and client first-render both produce null.
if (!mounted) return null;
if (!user) return null;
Expand All @@ -45,11 +50,16 @@ export default function OrdersPage() {
) : orders.length === 0 ? (
<p className="epicure-order-history__empty">{TEXT.orders.empty}</p>
) : (
<div className="epicure-order-history__list">
{orders.map(order => (
<OrderRow key={order.id} order={order} onSelect={setSelected} />
))}
</div>
<>
<div className="epicure-order-history__list">
{visibleOrders.map(order => (
<OrderRow key={order.id} order={order} onSelect={setSelected} />
))}
</div>
{hasMore && (
<div ref={sentinelRef} className="epicure-scroll-sentinel" aria-hidden="true" />
)}
</>
)}
{selected && <OrderSummaryModal order={selected} onClose={() => setSelected(null)} />}
</div>
Expand Down
27 changes: 6 additions & 21 deletions apps/frontend/src/components/ChefsGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import type { Chef } from '@org/shared-types';
import { ChefCard } from '@org/ui-components';
import { TEXT } from '../lib/text';
Expand All @@ -9,6 +9,7 @@ import { CHEF_HIGHLIGHT_EVENT, PENDING_CHEF_KEY } from '../lib/events';
import { ChefModal } from './ChefModal';
import { useIntersectionObserver } from '../hooks/useIntersectionObserver';
import { useTabIndicator } from '../hooks/useTabIndicator';
import { useInfiniteScroll } from '../hooks/useInfiniteScroll';

const PAGE_SIZE = 6;

Expand All @@ -20,11 +21,9 @@ export function ChefsGrid() {
const [activeTab, setActiveTab] = useState<ChefTab>('all');
const [highlightId, setHighlightId] = useState<number | null>(null);
const [selectedChef, setSelectedChef] = useState<Chef | null>(null);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const gridRef = useRef<HTMLDivElement>(null);
const gridVisible = useIntersectionObserver(gridRef);
const tabsRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const highlightPendingRef = useRef(false);
useTabIndicator(tabsRef, activeTab);

Expand Down Expand Up @@ -57,22 +56,8 @@ export function ChefsGrid() {
return chefs;
}, [chefs, activeTab]);

useEffect(() => {
if (highlightPendingRef.current) return;
setVisibleCount(PAGE_SIZE);
}, [filtered]);

const loadMore = useCallback(() => setVisibleCount(prev => prev + PAGE_SIZE), []);

useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadMore();
}, { threshold: 0 });
observer.observe(sentinel);
return () => observer.disconnect();
}, [loadMore]);
const { visibleItems: visibleChefs, hasMore, sentinelRef, setVisibleCount } =
useInfiniteScroll(filtered, PAGE_SIZE, highlightPendingRef);

useEffect(() => {
if (!highlightId || chefs.length === 0) return;
Expand Down Expand Up @@ -133,7 +118,7 @@ export function ChefsGrid() {
ref={gridRef}
className={`epicure-chef-grid${gridVisible ? ' epicure-chef-grid--visible' : ''}`}
>
{filtered.slice(0, visibleCount).map(chef => (
{visibleChefs.map(chef => (
<button
key={chef.id}
id={`chef-${chef.id}`}
Expand All @@ -145,7 +130,7 @@ export function ChefsGrid() {
<ChefCard chef={chef} imageUrl={strapiImageUrl(chef.image?.url)} />
</button>
))}
{visibleCount < filtered.length && (
{hasMore && (
<div ref={sentinelRef} className="epicure-scroll-sentinel" aria-hidden="true" />
)}
</div>
Expand Down
27 changes: 5 additions & 22 deletions apps/frontend/src/components/RestaurantsGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from 'react';
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
import dynamic from 'next/dynamic';
import type { Restaurant } from '@org/shared-types';
import { RestaurantCard } from '@org/ui-components';
Expand All @@ -14,6 +14,7 @@ import { fetchApi, fetchRestaurantsWithDistances, strapiImageUrl } from '../lib/
import { CUISINE_FILTER_EVENT, PENDING_CUISINE_KEY } from '../lib/events';
import { useLocationPermission } from '../hooks/useLocationPermission';
import { useTabIndicator } from '../hooks/useTabIndicator';
import { useInfiniteScroll } from '../hooks/useInfiniteScroll';
import { isOpenNow } from '../lib/openingHours';

const MapView = dynamic(() => import('./MapView').then(m => m.MapView), { ssr: false });
Expand Down Expand Up @@ -47,8 +48,6 @@ export function RestaurantsGrid() {
const [distanceKm, setDistanceKm] = useState<number | null>(null);
const [selectedCuisines, setSelectedCuisines] = useState<Set<string>>(new Set());
const [openFilterId, setOpenFilterId] = useState<string | null>(null);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const sentinelRef = useRef<HTMLDivElement>(null);

// Fetch restaurants immediately — no location dependency.
// Guard: skip setRestaurants if distances already arrived (prevents race overwrite).
Expand Down Expand Up @@ -157,23 +156,7 @@ export function RestaurantsGrid() {
[restaurants, selectedRatings, priceRange, globalPrices, activeTab, distanceKm, selectedCuisines],
);

// Reset to first page whenever the filtered set changes
useEffect(() => { setVisibleCount(PAGE_SIZE); }, [filtered]);

// Load next page when sentinel scrolls into view
const loadMore = useCallback(() => {
setVisibleCount(prev => prev + PAGE_SIZE);
}, []);

useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) loadMore();
}, { threshold: 0.1 });
observer.observe(sentinel);
return () => observer.disconnect();
}, [loadMore]);
const { visibleItems: visibleRestaurants, hasMore, sentinelRef } = useInfiniteScroll(filtered, PAGE_SIZE);

function clearAllFilters() {
setSelectedRatings(new Set());
Expand Down Expand Up @@ -334,11 +317,11 @@ export function RestaurantsGrid() {
) : (
<>
<div className="epicure-restaurant-grid">
{filtered.slice(0, visibleCount).map(restaurant => (
{visibleRestaurants.map(restaurant => (
<RestaurantCard key={restaurant.id} restaurant={restaurant} imageUrl={strapiImageUrl(restaurant.image?.url)} />
))}
</div>
{visibleCount < filtered.length && (
{hasMore && (
<div ref={sentinelRef} className="epicure-scroll-sentinel" aria-hidden="true" />
)}
</>
Expand Down
Loading
Loading