diff --git a/README.md b/README.md index 1c11394..dc748eb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,35 @@ # ByteBite +## Quick Start + +### Run it locally with Docker Compose + +Requires Docker Desktop. + +```bash +cp .env.example .env # then set LOGOS_KEY=... to enable the LLM (Optional step, otw canned responses) +docker compose up --build +``` + +Open http://localhost:8081. + +Also started by compose: [Prometheus](http://localhost:9090) and +[Grafana](http://localhost:3000) (`admin` / `bytebite`), and the +[Swagger UI](http://localhost:8080/swagger-ui.html). + +### The deployed app + +| | URL | Login | +|---|---|---| +| **App** (Kubernetes) | https://team-bytebite.stud.k8s.aet.cit.tum.de | `admin@bytebite.dev` / `password` | +| **Swagger UI** | https://team-bytebite.stud.k8s.aet.cit.tum.de/swagger-ui.html | — | +| **Grafana** | https://team-bytebite.stud.k8s.aet.cit.tum.de/grafana | `admin` / `bytebite` | + +The app also deploys to an **Azure VM** at `http://:8081`, with Prometheus on `:9090` +and Grafana on `:3000`. The IP is assigned by Terraform, get it from the GitHub Actions result. + +Both deployments run automatically on every merge to `main`. + ## 1. Problem Statement Cooking a new meal often starts with inspiration from a blog, a social media post, or a handwritten note. However, the transition from "finding a recipe" to "having the ingredients" is filled with friction. Users often have to manually read through long descriptions, identify specific ingredients, estimate quantities, and then rewrite them into a categorized list suitable for a grocery store layout. @@ -9,7 +39,8 @@ Cooking a new meal often starts with inspiration from a blog, a social media pos The core of the application is an intelligent parser that transforms unconcrete recipes into clearly structured lists with quantity estimations, allowing an improved shopping and cooking experience. Key features include: * **Recipe Extraction:** Paste a full recipe text (including stories or instructions), and the app extracts only the necessary ingredients. Alternatively, paste the name of a recipe and the app will generate a full ingredients list. * **Intelligent Categorization:** Ingredients are automatically grouped by grocery store aisles (e.g., Produce, Dairy, Spices, Meat). -* **Dietary & Allergy Filtering:** Users can state preferences (e.g., Vegan, Vegetarian, Gluten-Free, Lactose-Free). The app automatically identifies "red flag" ingredients and suggests safe alternatives. +* **Dietary Filtering & Substitution:** Users can state preferences (Vegan, Vegetarian, Gluten Free, Lactose Free). The app flags "red flag" ingredients and suggests a safe alternative for each. +* **Cross-Recipe Merging:** Combine several recipes into one shopping list. Duplicates are summed, and ingredients that mean the same thing under different names are merged into a single entry. ## 3. Intended Users * **The Busy Professional:** Someone who wants to cook healthy meals but lacks the time to manually plan grocery trips. @@ -17,22 +48,41 @@ The core of the application is an intelligent parser that transforms unconcrete * **Students on a Budget:** Users who need to ensure they only buy exactly what they need for a specific set of meals to avoid food waste. ## 4. Meaningful GenAI Integration -Unlike traditional apps that rely on rigid "If/Then" logic or specific formatting, ByteBite uses Generative AI (LLMs) to leverage its knowledge of countless recipes to generate custom grocery lists personalized to the users instructions: -* **Substitution Logic:** If a recipe calls for an obscure ingredient, the GenAI can suggest common alternatives directly on the shopping list. -* **Scaling & Adjustments:** Users can ask the AI to "Scale this recipe for 6 people instead of 2," and the shopping list will update dynamically using the AI's mathematical reasoning. +Unlike traditional apps that rely on rigid "If/Then" logic or specific input formatting, ByteBite uses Generative AI (LLMs) to leverage its knowledge of countless recipes. Each of the following would be impractical to implement with pattern matching or a fixed ingredient database: +* **Extraction from Unstructured Text:** The input is free-form, a dish name, a tidy ingredient list, or a rambling blog post. The model decides what is an ingredient and what is the author's story about their grandmother. No parser, delimiter, or expected format. +* **Knowledge-Based Generation:** Given only a dish name, the model produces the ingredients a typical recipe requires, drawing on what it already knows about that dish. There is no recipe database behind this. +* **Semantic Categorization:** Ingredients are assigned to a store aisle by meaning, not by lookup, "fresh basil" goes to Produce while "dried basil" goes to Spices, and "canned tomatoes" to Pantry while "fresh tomatoes" go to Produce. +* **Dietary Substitution:** Ingredients that violate a stated restriction are flagged, and the model proposes a substitute that fits the dish rather than a generic swap, lactose-free yogurt for heavy cream in a pan sauce. +* **Synonym-Aware Merging:** When several recipes are combined, the model recognizes that "cilantro" and "coriander" are the same purchase and sums them, while keeping "garlic clove" and "garlic powder" apart. It converts mismatched units before adding quantities. + +Unit conversion to metric and quantity estimation for vague amounts ("salt to taste") also run through the model. ## 5. User Scenarios ### Scenario A: The Blog Post Parser * **User Action:** Jason finds a 2,000-word blog post about "The Best Sunday Roast." He copies the entire text, including the author's life story, and pastes it into ByteBite. -* **App Action:** The AI ignores the anecdotes about the author's grandmother and generates a clean list: "1.5kg Beef Brisket, 4 Large Carrots, 2 Sprigs of Rosemary." +* **App Action:** The AI ignores the anecdotes about the author's grandmother and generates a clean, metric list: "1500 g Beef Brisket" (Meat), "4 piece Carrots" (Produce), "Fresh Rosemary, N/A" (Produce), each already sorted into its aisle. ### Scenario B: The Lactose Dilemma -* **User Action:** Mark asks for a recipe for Chicken Piccata but wants to know if he can swap the heavy cream for something lactose free. -* **App Action:** He asks the integrated AI assistant. The AI suggests using lactose free yogurt and automatically updates his shopping list with the alternative ingredient. +* **User Action:** Mark wants to cook Chicken Piccata, but he is lactose intolerant. He enters the dish and selects the **Lactose Free** filter before generating. +* **App Action:** The list comes back with heavy cream marked as restricted and shown alongside a suggested swap, lactose free yogurt, so Mark can see at a glance which item to replace and what to buy instead. ### Scenario C: Weekly Meal Prep -* **User Action:** A user adds three different recipes for the week: Tacos, Stir-fry, and Salad. -* **App Action:** The app identifies that all three recipes require "cilantro" and "lime." Instead of three separate entries, it provides a total count (e.g., "2 Bunches of Cilantro, 4 Limes") and sorts them into the 'Produce' section for a single trip through that aisle. +* **User Action:** A user saves three recipes for the week, Tacos, Stir-fry, and Salad, then selects all three and merges them into a single grocery list. +* **App Action:** The AI combines the three ingredient lists into one. Limes appear in all three recipes, so their quantities are added into a single entry instead of three. It also recognizes that the "coriander" in the Stir-fry and the "cilantro" in the Tacos are the same purchase and merges those too. The result is one aisle-sorted list with nothing bought twice. + +## 6. Responsibilities + +The project is split across three students, each owning one application area and one operations area. + +| Student | Application | Operations | +| --- | --- | --- | +| **Jonathan** | GenAI: FastAPI service, prompt design, LLM providers | Azure deployment: Terraform and Ansible | +| **Malik** | Server: API gateway, user service, grocery service | Monitoring: Prometheus, Grafana dashboards and alerting | +| **Tim** | Client: React frontend | Kubernetes deployment | + +These are main responsibilities, not exclusive ownership. The areas overlap in practice, and everyone +contributed outside their own column. The exact task distribution is tracked on the +[project board](https://github.com/AET-DevOps26/team-bytebite/projects). ## Project Layout @@ -41,27 +91,43 @@ team-bytebite/ ├── client/ # React + Vite frontend ├── gen-ai/ # Python FastAPI AI generation service ├── server/ # Java Spring Boot microservices -│ ├── api-gateway/ # Public entrypoint — routes requests to backend services +│ ├── api-gateway/ # Public entrypoint, routes requests to backend services │ ├── user-service/ # User domain service │ └── grocery-service/ # Grocery and recipe domain service -└── databases/ # Database image definitions and init schemas +├── databases/ # Database image definitions and init schemas +├── helm/ # Helm chart for the Kubernetes deployment +├── infra/ # Terraform (Azure VM) + Ansible (configure & deploy) +├── monitoring/ # Prometheus scrape config, Grafana dashboards and alerts +└── documentation/ # Architecture diagrams (DrawIO + exported images) ``` +Each directory has its own README with the details specific to it. + +## Architecture Diagrams + +Diagrams live in [documentation/](documentation/), as both editable `.drawio` sources and exported +images: + +- [Component Diagram](documentation/ComponentDiagram.png), how the services fit together +- [Class Diagram](documentation/ClassDiagram.png), the domain model +- [DB Schema Diagram](documentation/DBSchemaDiagram.png), the user and grocery databases +- [Use Case Diagram](documentation/UseCaseDiagram.png), what users can do + ## Services -### `client` — React / Vite +### `client`, React / Vite The user-facing web application. Provides a dish name input and displays the generated shopping list. Communicates with the backend via REST. -### `api-gateway` — Java Spring Boot +### `api-gateway`, Java Spring Boot The public backend entrypoint. Receives frontend API requests and forwards them to the owning backend service. -### `user-service` — Java Spring Boot +### `user-service`, Java Spring Boot Owns user-related data and connects to the user database. -### `grocery-service` — Java Spring Boot +### `grocery-service`, Java Spring Boot Owns recipes, grocery lists, and grocery items. Connects to the grocery database and calls the gen-ai service when ingredient generation is needed. -### `gen-ai` — Python FastAPI +### `gen-ai`, Python FastAPI The AI generation service. Receives a dish name from the server and returns a shopping list with all required ingredients using LLM integrations. ## Getting Started @@ -72,7 +138,7 @@ Each service has its own detailed setup instructions in its respective directory Requires Java 21, Node 22, and Python 3.12. Each service runs in its own terminal. -**1. Gen-AI** (port 8000) — create `gen-ai/.env` with `LOGOS_KEY=...`; add `OPENAI_API_KEY=sk-...` if you want to use the OpenAI switch. A local, offline option via [LM Studio](https://lmstudio.ai/) is also available — see `gen-ai/README.md`. +**1. Gen-AI** (port 8000), create `gen-ai/.env` with `LOGOS_KEY=...`; add `OPENAI_API_KEY=sk-...` if you want to use the OpenAI switch. A local, offline option via [LM Studio](https://lmstudio.ai/) is also available, see `gen-ai/README.md`. ```bash cd gen-ai python -m venv .venv @@ -122,24 +188,69 @@ The UI includes the User Service, Grocery Service, and Gen AI Service OpenAPI de ### Testing -The Java server components have unit and lightweight integration tests: +Every service is tested, and no test needs a running backend, database, or API key. + +**Java** (JUnit), unit and lightweight integration tests: - `api-gateway`: JWT gateway filter behavior, protected-route rejection, and trusted `X-User-*` header injection. - `user-service`: registration/login validation, password hashing behavior, current-user lookup, and JWT signing/verification. - `grocery-service`: grocery item mapping, list create/update behavior, merge behavior around Gen AI responses/failures, and controller HTTP behavior. -Run them locally with: - ```bash cd server/api-gateway && ./mvnw test cd server/user-service && ./mvnw test cd server/grocery-service && ./mvnw test ``` -GitHub Actions runs the same Maven test matrix in `Test, Build and Push Images` -before building/pushing images. Automatic Kubernetes and Azure deployments are -triggered only after that workflow succeeds; manual deployment workflow runs do -not rerun the Java tests. +**Client** (Vitest + React Testing Library), unit tests for the API↔view-model mappers, component +tests driving real user interactions, and integration tests over the whole `App` with `fetch` +mocked at the network boundary. See [client/README.md](client/README.md#testing). + +```bash +cd client && npm test +``` + +**Gen-AI** (pytest), both endpoints and their fallback paths, provider selection, prompt +construction, and JSON recovery, with the LLM client stubbed. [`pytest.ini`](gen-ai/pytest.ini) +enforces 85% coverage of `main.py`. See [gen-ai/README.md](gen-ai/README.md#tests). + +```bash +cd gen-ai && pip install -r requirements-dev.txt && pytest +``` + +All three suites run in CI on every push. + +--- + +### CI/CD + +Three GitHub Actions workflows, in [.github/workflows/](.github/workflows/): + +| Workflow | Trigger | What it does | +|---|---|---| +| [Test, Build and Push Images](.github/workflows/test-build-push.yml) | every push, any branch | Runs the Java, client, and gen-ai test suites in parallel. Only if all three pass does it build the Docker images. Images are pushed to GHCR on `main` only. | +| [Deploy to Kubernetes](.github/workflows/deploy-k8s.yml) | green build of `main` | `helm upgrade --install` to the AET cluster. | +| [Provision and Deploy](.github/workflows/deploy-azure.yml) | green build of `main` | `terraform apply` for the Azure VM, then the Ansible playbook to deploy onto it. | + +So a failing test on any branch blocks the image build, and merging to `main` deploys to both +targets automatically. Both deploy workflows can also be run on demand from the Actions tab; a +manual run does not rerun the tests. + +--- + +### Linting / Static Analysis + +Every service is linted in CI (`Test, Build and Push Images`) as a **blocking gate**. +A lint failure fails the build and prevents images from being pushed or deployed. + +| Service | Tool | Run locally | +|---------|------|-------------| +| `client` | ESLint | `cd client && npm run lint` | +| `gen-ai` | [Ruff](https://docs.astral.sh/ruff/) | `cd gen-ai && ruff check .` | +| `api-gateway`, `user-service`, `grocery-service` | [Spotless](https://github.com/diffplug/spotless) (google-java-format) | `cd server/ && ./mvnw spotless:check` | + +For the Java services, auto-format any violations with `./mvnw spotless:apply`. +Ruff config lives in `gen-ai/pyproject.toml`; ESLint config in `client/eslint.config.js`. --- @@ -163,6 +274,9 @@ Ruff config lives in `gen-ai/pyproject.toml`; ESLint config in `client/eslint.co Requires Docker Desktop running. +Copy [`.env.example`](.env.example) to `.env` and fill in `LOGOS_KEY` first. Compose reads it and +passes it to gen-ai. Without it, gen-ai still runs but serves a canned example ingredient list. + ```powershell docker compose up --build docker compose down # To take down later @@ -179,14 +293,14 @@ from all backend services. The Spring services expose metrics at `/actuator/prometheus` (via Spring Boot Actuator + Micrometer) and `gen-ai` exposes them at `/metrics`. -Open the Prometheus UI at http://localhost:9090 — check http://localhost:9090/targets +Open the Prometheus UI at http://localhost:9090, check http://localhost:9090/targets to confirm every service is `UP`. The scrape configuration lives in [`monitoring/prometheus.yml`](monitoring/prometheus.yml). -Open Grafana at http://localhost:3000 and log in with `admin` / `bytebite` unless -you override `GRAFANA_ADMIN_USER` and `GRAFANA_ADMIN_PASSWORD`. Grafana is -provisioned with the Prometheus datasource and a `ByteBite / ByteBite Overview` -dashboard from [`monitoring/grafana`](monitoring/grafana). +Open Grafana at http://localhost:3000 and log in with `admin` / `bytebite`, unless you override +`GRAFANA_ADMIN_USER` and `GRAFANA_ADMIN_PASSWORD`. Grafana is provisioned with the Prometheus +datasource and a `ByteBite / ByteBite Overview` dashboard from +[`monitoring/grafana`](monitoring/grafana). Grafana also provisions a `ByteBite service down` alert. It evaluates the Prometheus `up` metric every 30 seconds and fires when any scraped ByteBite @@ -204,7 +318,7 @@ Requires a local Kubernetes cluster running via Docker Desktop. ```powershell kubectl config use-context docker-desktop kubectl create namespace team-bytebite -helm upgrade --install bytebite ./helm/bytebite -f ./helm/bytebite/values-local.yaml --namespace team-bytebite --set genai.openaiApiKey="sk-..." --atomic +helm upgrade --install bytebite ./helm/bytebite -f ./helm/bytebite/values-local.yaml --namespace team-bytebite --set genai.logosKey="lg-..." --atomic helm uninstall bytebite --namespace team-bytebite # To take down later ``` @@ -214,7 +328,7 @@ The local Helm values also expose monitoring services when supported by your local cluster: - Prometheus: http://localhost:9090 -- Grafana: http://localhost:3000 (`admin` / `admin`, the chart fallback) +- Grafana: http://localhost:3000 (`admin` / `bytebite`) #### Kubernetes Deployment to the AET cluster @@ -231,7 +345,7 @@ Alternatively, you can do manual deployment with Helm: ```bash kubectl config use-context stud -helm upgrade --install bytebite ./helm/bytebite --namespace team-bytebite --set genai.openaiApiKey="sk-..." --atomic +helm upgrade --install bytebite ./helm/bytebite --namespace team-bytebite --set genai.logosKey="lg-..." --atomic helm uninstall bytebite --namespace team-bytebite # To take down later ``` @@ -250,4 +364,29 @@ For evaluation, both the deployed app and Grafana come with a ready-to-use login The app account is seeded by [`databases/user-db/init.sql`](databases/user-db/init.sql); you can also self-register a new account. The Grafana password is supplied at deploy time via the -`GRAFANA_ADMIN_PASSWORD` GitHub Actions secret — the chart's built-in fallback is `admin` / `admin`. +`GRAFANA_ADMIN_PASSWORD` GitHub Actions secret, and the chart falls back to the same +`admin` / `bytebite` used everywhere else. + +--- + +### Azure VM (Terraform + Ansible) + +A second, independent deployment target: a single Azure VM running the same `compose.yaml` stack. +Infrastructure and configuration are split across two tools. + +| Step | Tool | What it does | +| --- | --- | --- | +| Provision | [Terraform](infra/terraform/) | Resource group, network, NSG, public IP, Ubuntu VM, SSH keypair. State lives remotely in Azure Storage. Writes an Ansible inventory + SSH key on `apply`. | +| Configure & deploy | [Ansible](infra/ansible/) | Installs Docker, copies `compose.yaml` + monitoring config + a rendered `.env`, logs into GHCR, and runs `docker compose up -d --pull=always`. Images are pulled, never built on the VM. | + +[`.github/workflows/deploy-azure.yml`](.github/workflows/deploy-azure.yml) (*Provision and Deploy*) +runs both steps in one job, automatically after a green build of `main` and on demand via +*Run workflow*. `terraform apply` is idempotent, so an unchanged infrastructure is a no-op and only +the Ansible deploy does work. + +To run it by hand, see [infra/terraform/README.md](infra/terraform/README.md) followed by +[infra/ansible/README.md](infra/ansible/README.md), Terraform hands its generated inventory and +SSH key straight to Ansible. + +After a deploy, the app is at `http://:8081`, Prometheus at `http://:9090`, +and Grafana at `http://:3000`. diff --git a/client/README.md b/client/README.md index af63f16..033f27f 100644 --- a/client/README.md +++ b/client/README.md @@ -4,7 +4,7 @@ React + Vite + TypeScript frontend for ByteBite. ## Prerequisites -- Node 18+ +- Node 22 (matches the CI runner and [Dockerfile](Dockerfile)) ## Setup & Run @@ -38,16 +38,16 @@ npx vitest run # run a single file, e.g. src/lib/mappers.test.ts Tests sit next to the code they exercise (`*.test.ts` / `*.test.tsx`), in three tiers: -- **Unit** — [src/lib/mappers.test.ts](src/lib/mappers.test.ts): the pure API↔view-model +- **Unit** [src/lib/mappers.test.ts](src/lib/mappers.test.ts): the pure API↔view-model converters (quantity `null ↔ "N/A"`, item-payload mapping, derived counts). -- **Component** — [src/components/](src/components/): `AuthCard`, `ItemListForm` and +- **Component** [src/components/](src/components/): `AuthCard`, `ItemListForm` and `GroceryListView` rendered in isolation, driving real user interactions (form validation, add/remove rows, optimistic toggle with rollback, loading/error/empty states). -- **Integration** — [src/App.integration.test.tsx](src/App.integration.test.tsx): the real `App` - with `fetch` mocked at the network boundary, covering core workflows — login bootstrap, session +- **Integration** [src/App.integration.test.tsx](src/App.integration.test.tsx): the real `App` + with `fetch` mocked at the network boundary, covering core workflows, login bootstrap, session expiry, merging recipes into a grocery list across views, manual create, optimistic-delete rollback, and failed-load retry. -No network or backend is needed — `fetch` is mocked, so the suite is fast and deterministic. It runs +No network or backend is needed, `fetch` is mocked, so the suite is fast and deterministic. It runs automatically in CI (see [`.github/workflows/test-build-push.yml`](../.github/workflows/test-build-push.yml)) and gates image builds, so a failing test blocks the merge. diff --git a/client/package-lock.json b/client/package-lock.json index f48c8c1..ca309d8 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -13,6 +13,7 @@ "lucide-react": "^1.16.0", "react": "^19.2.5", "react-dom": "^19.2.5", + "react-router-dom": "^7.18.1", "tailwindcss": "^4.3.0" }, "devDependencies": { @@ -2003,6 +2004,19 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3321,6 +3335,44 @@ "license": "MIT", "peer": true }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -3409,6 +3461,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/client/package.json b/client/package.json index c3828fd..53dcca3 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,7 @@ "lucide-react": "^1.16.0", "react": "^19.2.5", "react-dom": "^19.2.5", + "react-router-dom": "^7.18.1", "tailwindcss": "^4.3.0" }, "devDependencies": { diff --git a/client/src/App.integration.test.tsx b/client/src/App.integration.test.tsx index 7f65fd0..0897ea9 100644 --- a/client/src/App.integration.test.tsx +++ b/client/src/App.integration.test.tsx @@ -1,15 +1,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' import App from './App' -import type { AuthPayload } from './components/AuthCard' import type { - ApiRecipe, ApiRecipeSummary, ApiGroceryList, ApiGroceryListSummary, + AuthPayload, ApiRecipe, ApiRecipeSummary, ApiGroceryList, ApiGroceryListSummary, } from './types' // These tests render the real App and mock `fetch` at the network boundary, so they exercise the -// wiring in App.tsx — token threading, the API↔view-model mappers, and state created in one view -// that surfaces in another — which the isolated component tests can't reach. +// wiring App owns — the session and api layer, the API↔view-model mappers, routing, and state +// created on one page that surfaces on another — which the isolated component tests can't reach. +// The router lives outside App (main.tsx supplies BrowserRouter), so the tests supply their own. // ── A tiny declarative fake backend ───────────────────────────────────────────────────────── type ResponseSpec = { status?: number; body?: unknown } @@ -73,8 +74,17 @@ const okRecipes = { method: 'GET', match: '/api/recipes', respond: { body: recip const okGroceryEmpty = { method: 'GET', match: '/api/grocery-list', respond: { body: [] as ApiGroceryListSummary[] } } as Route const okSession = { method: 'GET', match: '/api/users/me', respond: { body: auth } } as Route +function renderApp(initialPath = '/') { + return render( + + + + ) +} + +// The sidebar navigates with real links now, so these are anchors rather than buttons. function gotoView(user: ReturnType, name: 'Recipes' | 'Grocery Lists') { - return user.click(screen.getByRole('button', { name })) + return user.click(screen.getByRole('link', { name })) } beforeEach(() => { @@ -93,7 +103,7 @@ describe('auth bootstrap', () => { okRecipes, okGroceryEmpty, ]) - render() + renderApp() // Starts on the auth screen. expect(screen.getByText('Sign in to continue')).toBeInTheDocument() @@ -116,7 +126,7 @@ describe('auth bootstrap', () => { it('drops back to the auth screen when the stored session is rejected', async () => { seedSession() installApi([{ method: 'GET', match: '/api/users/me', respond: { status: 401 } }]) - render() + renderApp() expect(await screen.findByText('Sign in to continue')).toBeInTheDocument() expect(localStorage.getItem('bytebite-token')).toBeNull() @@ -133,7 +143,7 @@ describe('recipe workflows', () => { okGroceryEmpty, { method: 'POST', match: '/api/grocery-list/merge', respond: { body: mergedList } }, ]) - render() + renderApp() await gotoView(user, 'Recipes') await screen.findByText('Pasta') @@ -147,11 +157,94 @@ describe('recipe workflows', () => { expect(await screen.findByText(/merged!/i)).toBeInTheDocument() - // The merged list lives in App state, so it shows up when we switch views. + // The merged list lives in the shared grocery-list state, so it is there when we navigate. await gotoView(user, 'Grocery Lists') expect(await screen.findByText('Pasta + Salad')).toBeInTheDocument() }) + it('merges a single selected recipe into a grocery list', async () => { + const user = userEvent.setup() + seedSession() + const singleList: ApiGroceryList = { + groceryListId: 'g8', + name: 'Pasta', + createdAt: '2026-01-04', + items: [{ itemId: 'i1', name: 'Tomato', quantity: 3, unit: '', category: 'PRODUCE', purchased: false }], + } + const fetchMock = installApi([ + okSession, + okRecipes, + okGroceryEmpty, + { method: 'POST', match: '/api/grocery-list/merge', respond: { body: singleList } }, + ]) + renderApp() + + await gotoView(user, 'Recipes') + await screen.findByText('Pasta') + + // One recipe is enough: the Merge button must be live with a single selection. + await user.click(screen.getAllByRole('checkbox')[0]) + const merge = screen.getByRole('button', { name: 'Merge' }) + expect(merge).toBeEnabled() + await user.click(merge) + + expect(await screen.findByText(/merged!/i)).toBeInTheDocument() + const post = fetchMock.mock.calls.find(([, init]) => (init as RequestInit)?.method === 'POST')! + expect(JSON.parse((post[1] as RequestInit).body as string).recipeIds).toEqual(['r1']) + }) + + // Generating on the Home page is the only place the dietary flags exist. They are never stored: + // the ingredient that clashes with the diet is saved as its alternative, so the recipe — and + // every grocery list merged from it — already names the thing the user should buy. + it('saves a diet-clashing ingredient under its alternative', async () => { + const user = userEvent.setup() + seedSession() + const generated = { + dish: 'Pancakes', + ingredients: [ + { name: 'Flour', quantity: '300', unit: 'g', category: 'PANTRY', restricted: false, alternative: null }, + { name: 'Milk', quantity: '200', unit: 'ml', category: 'DAIRY', restricted: true, alternative: 'oat milk' }, + ], + } + const saved: ApiRecipe = { + recipeId: 'r9', name: 'Pancakes', createdAt: '2026-01-06', + items: [ + { itemId: 'i1', name: 'Flour', quantity: 300, unit: 'g', category: 'PANTRY' }, + { itemId: 'i2', name: 'oat milk', quantity: 200, unit: 'ml', category: 'DAIRY' }, + ], + } + const fetchMock = installApi([ + okSession, + { method: 'GET', match: '/api/recipes/providers', respond: { body: { openaiAvailable: false } } }, + { method: 'GET', match: '/api/recipes', respond: { body: [] as ApiRecipeSummary[] } }, + okGroceryEmpty, + { method: 'POST', match: '/api/recipes/generate', respond: { body: generated } }, + { method: 'POST', match: '/api/recipes', respond: { body: saved } }, + ]) + renderApp() + + await user.type(await screen.findByPlaceholderText(/paste a recipe/i), 'Pancakes') + await user.click(screen.getByRole('button', { name: /generate recipe/i })) + + await waitFor(() => { + const post = fetchMock.mock.calls.find(([url, init]) => + String(url) === '/api/recipes' && (init as RequestInit)?.method === 'POST') + expect(post).toBeDefined() + }) + + const post = fetchMock.mock.calls.find(([url, init]) => + String(url) === '/api/recipes' && (init as RequestInit)?.method === 'POST')! + const body = JSON.parse((post[1] as RequestInit).body as string) + + // The milk was swapped for the oat milk; the unrestricted flour was left alone… + expect(body.items.map((item: { name: string }) => item.name)).toEqual(['Flour', 'oat milk']) + // …the swapped item kept the quantity and unit of the ingredient it replaced… + expect(body.items[1]).toMatchObject({ quantity: 200, unit: 'ml', category: 'DAIRY' }) + // …and no dietary fields were persisted, because there is nowhere to put them. + expect(body.items[1]).not.toHaveProperty('restricted') + expect(body.items[1]).not.toHaveProperty('alternative') + }) + it('creates a recipe from the manual editor and prepends it to the list', async () => { const user = userEvent.setup() seedSession() @@ -165,7 +258,7 @@ describe('recipe workflows', () => { okGroceryEmpty, { method: 'POST', match: '/api/recipes', respond: { body: created } }, ]) - render() + renderApp() await gotoView(user, 'Recipes') await screen.findByText('No recipes yet') @@ -188,7 +281,7 @@ describe('recipe workflows', () => { okGroceryEmpty, { method: 'DELETE', match: /\/api\/recipes\/r1$/, respond: { status: 500 } }, ]) - render() + renderApp() await gotoView(user, 'Recipes') await screen.findByText('Pasta') @@ -208,7 +301,7 @@ describe('recipe workflows', () => { { method: 'GET', match: '/api/recipes', respond: ({ count }) => (count === 1 ? { status: 500 } : { body: [recipeSummaries[0]] }) }, okGroceryEmpty, ]) - render() + renderApp() await gotoView(user, 'Recipes') expect(await screen.findByText(/couldn't load recipes/i)).toBeInTheDocument() @@ -216,4 +309,46 @@ describe('recipe workflows', () => { await user.click(screen.getByRole('button', { name: /try again/i })) expect(await screen.findByText('Pasta')).toBeInTheDocument() }) +}) + +// Every screen has its own URL now, which is what makes these possible at all — before the router, +// the app rendered whatever `view` state said and the address bar never moved. +describe('routing', () => { + it('opens a deep link straight to the page, without passing through Home', async () => { + seedSession() + installApi([okSession, okRecipes, okGroceryEmpty]) + renderApp('/recipes') + + expect(await screen.findByText('Pasta')).toBeInTheDocument() + expect(screen.queryByText(/AI-powered grocery assistant/i)).not.toBeInTheDocument() + }) + + it('sends an unauthenticated deep link through login and back to where it was headed', async () => { + const user = userEvent.setup() + installApi([ + { method: 'POST', match: '/api/auth/login', respond: { body: auth } }, + okRecipes, + okGroceryEmpty, + ]) + renderApp('/recipes') + + // The guard bounced us to the login screen… + expect(await screen.findByText('Sign in to continue')).toBeInTheDocument() + + await user.type(screen.getByLabelText('Email'), 'ada@example.com') + await user.type(screen.getByLabelText('Password'), 'supersecret') + const submit = screen.getAllByRole('button').find(b => b.getAttribute('type') === 'submit')! + await user.click(submit) + + // …and signing in returns us to /recipes rather than dumping us on Home. + expect(await screen.findByText('Pasta')).toBeInTheDocument() + }) + + it('redirects an unknown path to Home', async () => { + seedSession() + installApi([okSession, okRecipes, okGroceryEmpty]) + renderApp('/does-not-exist') + + expect(await screen.findByText(/AI-powered grocery assistant/i)).toBeInTheDocument() + }) }) \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index 3ca28d1..4976dcc 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,501 +1,105 @@ -import { useState, useEffect, useCallback } from 'react' -import { motion, AnimatePresence } from 'framer-motion' -import { Menu } from 'lucide-react' -import { Sidebar, LogoMark } from './components/Sidebar' -import { HeroSection } from './components/HeroSection' -import { RecipeCard } from './components/RecipeCard' -import { FeatureCards } from './components/FeatureCards' -import { AuthCard, type AuthPayload, type AuthUser } from './components/AuthCard' -import { GroceryListView } from './components/GroceryListView' -import { RecipeListView } from './components/RecipeListView' -import { ProfileView } from './components/ProfileView' -import type { - GroceryList, ApiRecipe, ApiRecipeSummary, RecipeSummary, Ingredient, - ApiGroceryList, ApiGroceryListSummary, GroceryListSummary, GroceryItemDetail, EditableItem, - LlmProvider, -} from './types' -import { - apiSummaryToRecipe, parseQuantity, toRecipeItemPayload, toGroceryItemPayload, - apiItemsToIngredients, apiSummaryToGroceryList, detailToGrocerySummary, apiItemsToGroceryDetail, -} from './lib/mappers' - -type View = 'home' | 'grocery-lists' | 'recipes' | 'profile' -type LoadStatus = 'loading' | 'ready' | 'error' - -const LLM_PROVIDER_STORAGE_KEY = 'bytebite:llmProvider' - -function getInitialDark(): boolean { - const stored = localStorage.getItem('bytebite-dark') - if (stored !== null) return stored === 'true' - return window.matchMedia('(prefers-color-scheme: dark)').matches -} - -function App() { - const [darkMode, setDarkMode] = useState(getInitialDark) - const [sidebarOpen, setSidebarOpen] = useState(false) - const [view, setView] = useState('home') - const [token, setToken] = useState(() => localStorage.getItem('bytebite-token') ?? '') - const [user, setUser] = useState(() => { - const stored = localStorage.getItem('bytebite-user') - return stored ? JSON.parse(stored) as AuthUser : null - }) - const [recipes, setRecipes] = useState([]) - const [recipesStatus, setRecipesStatus] = useState('loading') - const [groceryLists, setGroceryLists] = useState([]) - const [groceryStatus, setGroceryStatus] = useState('loading') - const [llmProvider, setLlmProvider] = useState(() => { - const stored = localStorage.getItem(LLM_PROVIDER_STORAGE_KEY) - return stored === 'openai' || stored === 'local' ? stored : 'logos' - }) - - useEffect(() => { - localStorage.setItem(LLM_PROVIDER_STORAGE_KEY, llmProvider) - }, [llmProvider]) - - const loadRecipes = useCallback((authToken: string) => { - setRecipesStatus('loading') - fetch('/api/recipes', { headers: { Authorization: `Bearer ${authToken}` } }) - .then(response => { - if (!response.ok) throw new Error('Failed to load recipes') - return response.json() as Promise - }) - .then(data => { - setRecipes(data.map(apiSummaryToRecipe)) - setRecipesStatus('ready') - }) - .catch(() => setRecipesStatus('error')) - }, []) - - const loadGroceryLists = useCallback((authToken: string) => { - setGroceryStatus('loading') - fetch('/api/grocery-list', { headers: { Authorization: `Bearer ${authToken}` } }) - .then(response => { - if (!response.ok) throw new Error('Failed to load grocery lists') - return response.json() as Promise - }) - .then(data => { - setGroceryLists(data.map(apiSummaryToGroceryList)) - setGroceryStatus('ready') - }) - .catch(() => setGroceryStatus('error')) - }, []) - - const fetchRecipeItems = useCallback(async (recipeId: string): Promise => { - const response = await fetch(`/api/recipes/${recipeId}`, { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!response.ok) throw new Error('Failed to load recipe items') - return apiItemsToIngredients(await response.json() as ApiRecipe) - }, [token]) - - const fetchGroceryListItems = useCallback(async (listId: string): Promise => { - const response = await fetch(`/api/grocery-list/${listId}`, { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!response.ok) throw new Error('Failed to load grocery list items') - return apiItemsToGroceryDetail(await response.json() as ApiGroceryList) - }, [token]) - - useEffect(() => { - document.documentElement.classList.toggle('dark', darkMode) - localStorage.setItem('bytebite-dark', String(darkMode)) - }, [darkMode]) - +import { useEffect } from 'react' +import { Navigate, Route, Routes } from 'react-router-dom' +import { AppLayout } from './components/layout/AppLayout' +import { RequireAuth } from './components/layout/RequireAuth' +import { AuthProvider } from './contexts/AuthProvider' +import { useAuth } from './contexts/authContext' +import { useDarkMode } from './hooks/useDarkMode' +import { useGroceryLists } from './hooks/useGroceryLists' +import { useLlmProvider } from './hooks/useLlmProvider' +import { useRecipes } from './hooks/useRecipes' +import { GroceryListsPage } from './pages/GroceryListsPage' +import { HomePage } from './pages/HomePage' +import { LoginPage } from './pages/LoginPage' +import { ProfilePage } from './pages/ProfilePage' +import { RecipesPage } from './pages/RecipesPage' + +// Holds the state the pages share — the two collections and the LLM choice — and hands each route +// exactly the slice it needs. Everything else (the session, the API, page chrome) lives in the +// provider, the hooks, and the layout. +function AppRoutes() { + const { status } = useAuth() + const { darkMode, toggleDark } = useDarkMode() + const { llmProvider, setLlmProvider } = useLlmProvider() + const recipes = useRecipes() + const groceryLists = useGroceryLists() + + const { load: loadRecipes, reset: resetRecipes } = recipes + const { load: loadGroceryLists, reset: resetGroceryLists } = groceryLists + + // Both collections belong to the session: they load once it is confirmed (a fresh sign-in or a + // revalidated token) and are dropped when it ends, so a second sign-in cannot show stale data. useEffect(() => { - if (!token) return - fetch('/api/users/me', { - headers: { Authorization: `Bearer ${token}` }, - }) - .then(response => { - if (!response.ok) throw new Error('Session expired') - return response.json() as Promise - }) - .then(payload => { - setToken(payload.token) - setUser(payload.user) - localStorage.setItem('bytebite-token', payload.token) - localStorage.setItem('bytebite-user', JSON.stringify(payload.user)) - loadRecipes(payload.token) - loadGroceryLists(payload.token) - }) - .catch(() => { - localStorage.removeItem('bytebite-token') - localStorage.removeItem('bytebite-user') - setToken('') - setUser(null) - }) - }, [loadRecipes, loadGroceryLists]) - - const toggleDark = () => setDarkMode(d => !d) - const openSidebar = () => setSidebarOpen(true) - const closeSidebar = () => setSidebarOpen(false) - - const navigate = (v: string) => { - setView(v as View) - setSidebarOpen(false) - } - - const handleAuthenticated = (payload: AuthPayload) => { - setToken(payload.token) - setUser(payload.user) - localStorage.setItem('bytebite-token', payload.token) - localStorage.setItem('bytebite-user', JSON.stringify(payload.user)) - loadRecipes(payload.token) - loadGroceryLists(payload.token) - } - - const handleLogout = () => { - localStorage.removeItem('bytebite-token') - localStorage.removeItem('bytebite-user') - setToken('') - setUser(null) - setRecipes([]) - setGroceryLists([]) - setView('home') - } - - // Updates name/email. The server re-issues the JWT (it embeds name/email), so we swap in the - // fresh token and user. Returns null on success or an error message for the form to surface. - const handleUpdateProfile = async (name: string, email: string): Promise => { - try { - const response = await fetch('/api/users/me', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ name, email }), - }) - const data = await response.json() - if (!response.ok) throw new Error(data.message || 'Failed to update profile.') - const payload = data as AuthPayload - setToken(payload.token) - setUser(payload.user) - localStorage.setItem('bytebite-token', payload.token) - localStorage.setItem('bytebite-user', JSON.stringify(payload.user)) - return null - } catch (err) { - return err instanceof Error ? err.message : 'Failed to update profile.' - } - } - - // Changes the password after verifying the current one server-side. On success the user is - // logged out so they must sign in again with the new password. Returns null on success. - const handleChangePassword = async (currentPassword: string, newPassword: string): Promise => { - try { - const response = await fetch('/api/users/me/password', { - method: 'PUT', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ currentPassword, newPassword }), - }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.message || 'Failed to change password.') - } - // Brief pause so the success banner is visible before the app drops back to the login screen. - setTimeout(handleLogout, 1200) - return null - } catch (err) { - return err instanceof Error ? err.message : 'Failed to change password.' + if (status === 'authenticated') { + loadRecipes() + loadGroceryLists() + } else if (status === 'anonymous') { + resetRecipes() + resetGroceryLists() } - } - - // A generated dish is saved as a recipe only; grocery lists are created later by merging - // recipes on the Recipes page. Returns true when the recipe was persisted so the Home - // page can confirm it to the user. - const handleListGenerated = async (list: GroceryList): Promise => { - if (!token) return false - - try { - const response = await fetch('/api/recipes', { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ - name: list.dish, - items: list.ingredients.map(item => ({ - name: item.name, - quantity: parseQuantity(item.quantity), - unit: item.unit, - category: item.category, - })), - }), - }) - if (!response.ok) throw new Error('Failed to save recipe') - const saved = await response.json() as ApiRecipe - setRecipes(prev => [apiSummaryToRecipe(saved), ...prev]) - return true - } catch { - return false - } - } - - const handleDeleteRecipe = (recipeId: string) => { - const previous = recipes - setRecipes(prev => prev.filter(recipe => recipe.id !== recipeId)) - fetch(`/api/recipes/${recipeId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }) - .then(response => { - if (!response.ok && response.status !== 404) throw new Error('Failed to delete recipe') - }) - .catch(() => setRecipes(previous)) - } - - // Creates a recipe from the manual editor and prepends it to the list. Returns false on failure - // so the form can keep itself open and surface an error. - const handleCreateRecipe = async (name: string, items: EditableItem[]): Promise => { - if (!token) return false - try { - const response = await fetch('/api/recipes', { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ name, items: items.map(toRecipeItemPayload) }), - }) - if (!response.ok) throw new Error('Failed to create recipe') - const saved = await response.json() as ApiRecipe - setRecipes(prev => [apiSummaryToRecipe(saved), ...prev]) - return true - } catch { - return false - } - } - - // Replaces a recipe's name and items. Updates the summary in place and returns the fresh items - // so the Recipes view can refresh its cached detail; returns null on failure. - const handleUpdateRecipe = async (id: string, name: string, items: EditableItem[]): Promise => { - if (!token) return null - try { - const response = await fetch(`/api/recipes/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ name, items: items.map(toRecipeItemPayload) }), - }) - if (!response.ok) throw new Error('Failed to update recipe') - const saved = await response.json() as ApiRecipe - setRecipes(prev => prev.map(recipe => (recipe.id === id ? apiSummaryToRecipe(saved) : recipe))) - return apiItemsToIngredients(saved) - } catch { - return null - } - } - - // Merges the selected recipes into a new grocery list server-side and prepends it to the - // history. Returns false on failure so the Recipes view can surface an error to the user. - const handleMergeRecipes = async (recipeIds: string[]): Promise => { - try { - const response = await fetch('/api/grocery-list/merge', { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ recipeIds, llmProvider }), - }) - if (!response.ok) throw new Error('Failed to merge recipes') - const saved = await response.json() as ApiGroceryList - setGroceryLists(prev => [detailToGrocerySummary(saved), ...prev]) - return true - } catch { - return false - } - } - - // Persists a single item's picked-up state via PATCH and keeps the summary counts in sync. - // Returns false if the server rejected the change so the view can revert its optimistic update. - const handleToggleGroceryItem = useCallback( - async (listId: string, itemId: string, purchased: boolean): Promise => { - const adjust = (delta: number) => setGroceryLists(prev => prev.map(list => - list.id === listId - ? { ...list, purchasedCount: Math.max(0, Math.min(list.itemCount, list.purchasedCount + delta)) } - : list - )) - adjust(purchased ? 1 : -1) - try { - const response = await fetch(`/api/grocery-list/${listId}/items/${itemId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ purchased }), - }) - if (!response.ok) throw new Error('Failed to update item') - return true - } catch { - adjust(purchased ? -1 : 1) - return false - } - }, - [token] - ) - - const handleDeleteList = (listId: string) => { - const previous = groceryLists - setGroceryLists(prev => prev.filter(list => list.id !== listId)) - fetch(`/api/grocery-list/${listId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }) - .then(response => { - if (!response.ok && response.status !== 404) throw new Error('Failed to delete grocery list') - }) - .catch(() => setGroceryLists(previous)) - } - - // Creates a grocery list from the manual editor and prepends it to the history. - const handleCreateList = async (name: string, items: EditableItem[]): Promise => { - if (!token) return false - try { - const response = await fetch('/api/grocery-list', { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ name, items: items.map(toGroceryItemPayload) }), - }) - if (!response.ok) throw new Error('Failed to create grocery list') - const saved = await response.json() as ApiGroceryList - setGroceryLists(prev => [detailToGrocerySummary(saved), ...prev]) - return true - } catch { - return false - } - } - - // Replaces a grocery list's name and items. The PUT reassigns item ids, so we return the fresh - // items for the view to reseed its cache; the summary counts are recomputed from the response. - const handleUpdateList = async (id: string, name: string, items: EditableItem[]): Promise => { - if (!token) return null - try { - const response = await fetch(`/api/grocery-list/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ name, items: items.map(toGroceryItemPayload) }), - }) - if (!response.ok) throw new Error('Failed to update grocery list') - const saved = await response.json() as ApiGroceryList - setGroceryLists(prev => prev.map(list => (list.id === id ? detailToGrocerySummary(saved) : list))) - return apiItemsToGroceryDetail(saved) - } catch { - return null - } - } - - if (!token || !user) { - return - } + }, [status, loadRecipes, loadGroceryLists, resetRecipes, resetGroceryLists]) return ( -
- - - {/* Mobile backdrop */} - - {sidebarOpen && ( - + } /> + + }> + }> + recipes.create(list.dish, list.ingredients)} + /> + } + /> + groceryLists.merge(recipeIds, llmProvider)} + /> + } /> - )} - + + } + /> + } /> + + - {/* Main content */} -
- {/* Mobile top bar */} -
- - -
+ } /> + + ) +} - {/* Page content */} -
- - {view === 'grocery-lists' ? ( - - loadGroceryLists(token)} - onToggleItem={handleToggleGroceryItem} - onDeleteList={handleDeleteList} - onCreateList={handleCreateList} - onUpdateList={handleUpdateList} - fetchItems={fetchGroceryListItems} - /> - - ) : view === 'profile' ? ( - - - - ) : view === 'recipes' ? ( - - loadRecipes(token)} - onDeleteRecipe={handleDeleteRecipe} - onCreateRecipe={handleCreateRecipe} - onUpdateRecipe={handleUpdateRecipe} - fetchItems={fetchRecipeItems} - onMerge={handleMergeRecipes} - /> - - ) : ( - - - - -

- ByteBite · AI-powered grocery assistant -

-
- )} -
-
-
-
+function App() { + return ( + + + ) } -export default App \ No newline at end of file +export default App diff --git a/client/src/components/FeatureCards.tsx b/client/src/components/FeatureCards.tsx index 480bd94..9cf13e5 100644 --- a/client/src/components/FeatureCards.tsx +++ b/client/src/components/FeatureCards.tsx @@ -5,17 +5,17 @@ const features: { icon: LucideIcon; title: string; desc: string }[] = [ { icon: Brain, title: 'Smart parsing', - desc: 'We understand ingredients, quantities, and units automatically.', + desc: 'Ingredients, quantities, and units are understood automatically.', }, { icon: LayoutList, title: 'Organized lists', - desc: 'Grouped by store sections for faster, stress-free shopping.', + desc: 'Grouped by store sections for faster shopping.', }, { icon: Leaf, - title: 'Reduce waste', - desc: 'Buy only what you need — nothing more, nothing less.', + title: 'Diet-aware', + desc: 'Automatically substitutes ingredients that clash with your dietary restrictions.', }, ] diff --git a/client/src/components/HeroSection.tsx b/client/src/components/HeroSection.tsx index 50e510d..444851d 100644 --- a/client/src/components/HeroSection.tsx +++ b/client/src/components/HeroSection.tsx @@ -14,7 +14,7 @@ export function HeroSection() {

- Turn any recipe into a{' '} + Turn your recipes into a{' '} smart {' '} @@ -22,8 +22,8 @@ export function HeroSection() {

- Paste a recipe or meal idea and ByteBite instantly organizes ingredients - for your next grocery run. + Paste a recipe or meal idea and let ByteBite create your next grocery + shopping list.

) diff --git a/client/src/components/RecipeCard.tsx b/client/src/components/RecipeCard.tsx index 3249ea7..4f256b3 100644 --- a/client/src/components/RecipeCard.tsx +++ b/client/src/components/RecipeCard.tsx @@ -1,7 +1,8 @@ import { useState, useEffect } from 'react' import { motion, AnimatePresence } from 'framer-motion' -import { ChefHat, ShoppingCart, ArrowRight, Loader2, AlertTriangle } from 'lucide-react' +import { ChefHat, ArrowRight, Loader2, AlertTriangle } from 'lucide-react' import { AlertBanner } from './AlertBanner' +import { useApi } from '../contexts/authContext' import type { Ingredient, GroceryList, LlmProvider } from '../types' type Status = 'idle' | 'loading' | 'success' | 'error' @@ -35,13 +36,15 @@ const PLACEHOLDERS = [ ] interface RecipeCardProps { - token: string llmProvider: LlmProvider onLlmProviderChange: (provider: LlmProvider) => void onListGenerated?: (list: GroceryList) => Promise } -export function RecipeCard({ token, llmProvider, onLlmProviderChange, onListGenerated }: RecipeCardProps) { +type GeneratedRecipe = { dish: string; ingredients: Ingredient[]; note?: string | null } + +export function RecipeCard({ llmProvider, onLlmProviderChange, onListGenerated }: RecipeCardProps) { + const api = useApi() const [input, setInput] = useState('') const [validationError, setValidationError] = useState('') const [status, setStatus] = useState('idle') @@ -53,13 +56,10 @@ export function RecipeCard({ token, llmProvider, onLlmProviderChange, onListGene const [note, setNote] = useState(null) useEffect(() => { - fetch('/api/recipes/providers', { - headers: { Authorization: `Bearer ${token}` }, - }) - .then(response => (response.ok ? response.json() : { openaiAvailable: false })) - .then((data: { openaiAvailable: boolean }) => setOpenaiAvailable(data.openaiAvailable)) + api.get<{ openaiAvailable: boolean }>('/recipes/providers') + .then(data => setOpenaiAvailable(data.openaiAvailable)) .catch(() => setOpenaiAvailable(false)) - }, [token]) + }, [api]) useEffect(() => { if (!openaiAvailable && llmProvider === 'openai') onLlmProviderChange('logos') @@ -95,16 +95,11 @@ export function RecipeCard({ token, llmProvider, onLlmProviderChange, onListGene setSavedToRecipes(false) setNote(null) try { - const response = await fetch('/api/recipes/generate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ dish: trimmed, dietaryRestrictions, llmProvider }), + const data = await api.post('/recipes/generate', { + dish: trimmed, + dietaryRestrictions, + llmProvider, }) - if (!response.ok) throw new Error('Request failed') - const data = await response.json() as { dish: string; ingredients: Ingredient[]; note?: string | null } setIngredients(data.ingredients) setNote(data.note ?? null) setStatus('success') @@ -277,8 +272,8 @@ export function RecipeCard({ token, llmProvider, onLlmProviderChange, onListGene ) : ( <> - - Generate shopping list + + Generate recipe )} @@ -304,14 +299,14 @@ export function RecipeCard({ token, llmProvider, onLlmProviderChange, onListGene
)}

- - Shopping list + + Recipe

{ingredients.length > 0 ? (
diff --git a/client/src/components/layout/AppLayout.tsx b/client/src/components/layout/AppLayout.tsx new file mode 100644 index 0000000..4044907 --- /dev/null +++ b/client/src/components/layout/AppLayout.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { Outlet } from 'react-router-dom' +import { Menu } from 'lucide-react' +import { Sidebar, LogoMark } from './Sidebar' + +interface AppLayoutProps { + darkMode: boolean + onToggleDark: () => void +} + +// The chrome every authenticated page sits inside: sidebar, mobile top bar, and the page slot. +export function AppLayout({ darkMode, onToggleDark }: AppLayoutProps) { + const [sidebarOpen, setSidebarOpen] = useState(false) + + const closeSidebar = () => setSidebarOpen(false) + + return ( +
+ + + {/* Mobile backdrop */} + + {sidebarOpen && ( + + )} + + + {/* Main content */} +
+ {/* Mobile top bar */} +
+ + +
+ + {/* Page content. Deliberately NOT wrapped in an AnimatePresence keyed on the pathname: in + wait mode that rebuilds the page subtree on every render, remounting the page and + discarding its state (an open editor, a half-typed form), and it also stops the modal's + own exit animation from completing. Each page already fades itself in on mount, so the + only thing given up is a 150ms exit fade between routes. */} +
+ +
+
+
+ ) +} diff --git a/client/src/components/layout/RequireAuth.tsx b/client/src/components/layout/RequireAuth.tsx new file mode 100644 index 0000000..c2160b7 --- /dev/null +++ b/client/src/components/layout/RequireAuth.tsx @@ -0,0 +1,16 @@ +import { Navigate, Outlet, useLocation } from 'react-router-dom' +import { useAuth } from '../../contexts/authContext' + +// The gate in front of every authenticated route, replacing the early `return ` that +// used to shadow the whole app. A stored session renders straight through while it is being +// revalidated, so a refresh never flashes the login screen; only a confirmed-anonymous session is +// redirected, and it carries the attempted location so login can send the user back to it. +export function RequireAuth() { + const { token, user } = useAuth() + const location = useLocation() + + if (!token || !user) { + return + } + return +} diff --git a/client/src/components/Sidebar.tsx b/client/src/components/layout/Sidebar.tsx similarity index 69% rename from client/src/components/Sidebar.tsx rename to client/src/components/layout/Sidebar.tsx index 3aa922d..b6421cb 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/layout/Sidebar.tsx @@ -1,15 +1,19 @@ import { motion, AnimatePresence } from 'framer-motion' +import { NavLink } from 'react-router-dom' import { Home, ShoppingCart, BookOpen, User, Sun, Moon, X, LogOut, Leaf, type LucideIcon, } from 'lucide-react' -import type { AuthUser } from './AuthCard' +import { useAuth } from '../../contexts/authContext' +import type { AuthUser } from '../../types' type NavItem = { icon: LucideIcon label: string - view?: string + to: string + // Only '/' needs exact matching — without it, Home would stay lit on every route. + end?: boolean } interface SidebarProps { @@ -17,20 +21,16 @@ interface SidebarProps { onToggleDark: () => void isOpen: boolean onClose: () => void - user: AuthUser - onLogout: () => void - activeView: string - onNavigate: (view: string) => void } const navMain: NavItem[] = [ - { icon: Home, label: 'Home', view: 'home' }, - { icon: BookOpen, label: 'Recipes', view: 'recipes' }, - { icon: ShoppingCart, label: 'Grocery Lists', view: 'grocery-lists' }, + { icon: Home, label: 'Home', to: '/', end: true }, + { icon: BookOpen, label: 'Recipes', to: '/recipes' }, + { icon: ShoppingCart, label: 'Grocery Lists', to: '/grocery-lists' }, ] const navAccount: NavItem[] = [ - { icon: User, label: 'Profile', view: 'profile' }, + { icon: User, label: 'Profile', to: '/profile' }, ] export function LogoMark({ scale = 'lg' }: { scale?: 'sm' | 'lg' }) { @@ -53,43 +53,38 @@ export function LogoMark({ scale = 'lg' }: { scale?: 'sm' | 'lg' }) { } function NavSection({ - label, items, activeView, onNavigate, + label, items, onNavigate, }: { label: string items: NavItem[] - activeView: string - onNavigate: (view: string) => void + // Closes the mobile drawer once a destination has been picked. + onNavigate: () => void }) { return (

{label}

- {items.map(({ icon: Icon, label: itemLabel, view }) => { - const active = view !== undefined && activeView === view - const clickable = view !== undefined - return ( - clickable && onNavigate(view!)} - disabled={!clickable} - className={`w-full flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-medium transition-colors ${ - !clickable ? 'opacity-40 cursor-default' : 'cursor-pointer' - } ${ - active - ? 'bg-green-50 text-green-800 dark:bg-green-900/30 dark:text-green-400' - : clickable - ? 'text-gray-600 dark:text-gray-400 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 hover:text-gray-900 dark:hover:text-gray-200' - : 'text-gray-600 dark:text-gray-400' - }`} - > - - {itemLabel} - - ) - })} + {items.map(({ icon: Icon, label: itemLabel, to, end }) => ( + `w-full flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-medium cursor-pointer transition-all hover:translate-x-0.5 ${ + isActive + ? 'bg-green-50 text-green-800 dark:bg-green-900/30 dark:text-green-400' + : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 hover:text-gray-900 dark:hover:text-gray-200' + }`} + > + {({ isActive }) => ( + <> + + {itemLabel} + + )} + + ))}
) } @@ -99,15 +94,13 @@ function SidebarContent({ onToggleDark, user, onLogout, - activeView, onNavigate, }: { darkMode: boolean onToggleDark: () => void user: AuthUser onLogout: () => void - activeView: string - onNavigate: (view: string) => void + onNavigate: () => void }) { return (
@@ -118,8 +111,8 @@ function SidebarContent({ {/* Nav */} {/* Bottom section */} @@ -158,12 +151,15 @@ function SidebarContent({ ) } -export function Sidebar({ darkMode, onToggleDark, isOpen, onClose, user, onLogout, activeView, onNavigate }: SidebarProps) { +export function Sidebar({ darkMode, onToggleDark, isOpen, onClose }: SidebarProps) { + const { user, signOut } = useAuth() + if (!user) return null + return ( <> {/* Desktop sidebar */} {/* Mobile sidebar */} @@ -182,7 +178,7 @@ export function Sidebar({ darkMode, onToggleDark, isOpen, onClose, user, onLogou > - + )} diff --git a/client/src/contexts/AuthProvider.tsx b/client/src/contexts/AuthProvider.tsx new file mode 100644 index 0000000..3474b3e --- /dev/null +++ b/client/src/contexts/AuthProvider.tsx @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' +import { createApiClient } from '../lib/api' +import type { AuthPayload, AuthUser } from '../types' +import { AuthContext, type AuthContextValue, type SessionStatus } from './authContext' + +const TOKEN_KEY = 'bytebite-token' +const USER_KEY = 'bytebite-user' + +function readStoredSession(): AuthPayload | null { + const token = localStorage.getItem(TOKEN_KEY) + const user = localStorage.getItem(USER_KEY) + if (!token || !user) return null + try { + return { token, user: JSON.parse(user) as AuthUser } + } catch { + return null + } +} + +// Owns the session. State and localStorage are only ever written together, in signIn/signOut, so +// the two cannot drift apart — which they could when each caller persisted them by hand. +export function AuthProvider({ children }: { children: ReactNode }) { + const [session, setSession] = useState(readStoredSession) + const [status, setStatus] = useState(() => (readStoredSession() ? 'restoring' : 'anonymous')) + + const token = session?.token ?? '' + + const signIn = useCallback((payload: AuthPayload) => { + setSession(payload) + setStatus('authenticated') + localStorage.setItem(TOKEN_KEY, payload.token) + localStorage.setItem(USER_KEY, JSON.stringify(payload.user)) + }, []) + + const signOut = useCallback(() => { + setSession(null) + setStatus('anonymous') + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(USER_KEY) + }, []) + + // Keyed on the token string rather than the session object, so the client — and with it every + // callback in the data hooks — keeps its identity across ordinary re-renders. It is rebuilt only + // when the token actually changes: signing in or out, or the server re-issuing the JWT after a + // profile edit (which also re-reads the collections under the new token). + const api = useMemo( + () => createApiClient({ getToken: () => token, onUnauthorized: signOut }), + [token, signOut] + ) + + // Revalidates a stored token on boot. The server returns a fresh token plus the current user, so + // a success is just another sign-in; a failure drops the session and the route guard sends the + // user to the login page. Guarded on 'restoring' so it runs exactly once, at startup. + useEffect(() => { + if (status !== 'restoring') return + api.get('/users/me').then(signIn).catch(signOut) + }, [status, api, signIn, signOut]) + + const value = useMemo( + () => ({ + user: session?.user ?? null, + token, + status, + signIn, + signOut, + api, + }), + [session, token, status, signIn, signOut, api] + ) + + return {children} +} diff --git a/client/src/contexts/authContext.ts b/client/src/contexts/authContext.ts new file mode 100644 index 0000000..292823f --- /dev/null +++ b/client/src/contexts/authContext.ts @@ -0,0 +1,31 @@ +// The session context itself, kept apart from the provider component so that this file exports +// only hooks (react-refresh requires a component file to export nothing but components). +import { createContext, useContext } from 'react' +import type { ApiClient } from '../lib/api' +import type { AuthPayload, AuthUser } from '../types' + +// 'restoring' means a token was found in storage and is being revalidated against the server. The +// app renders optimistically during that window, so it never flashes the login screen on refresh. +export type SessionStatus = 'restoring' | 'authenticated' | 'anonymous' + +export type AuthContextValue = { + user: AuthUser | null + token: string + status: SessionStatus + signIn: (payload: AuthPayload) => void + signOut: () => void + api: ApiClient +} + +export const AuthContext = createContext(null) + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext) + if (!context) throw new Error('useAuth must be used within an AuthProvider') + return context +} + +// Sugar for the common case: a component that needs to call the API but not read the session. +export function useApi(): ApiClient { + return useAuth().api +} diff --git a/client/src/hooks/useDarkMode.ts b/client/src/hooks/useDarkMode.ts new file mode 100644 index 0000000..305eda7 --- /dev/null +++ b/client/src/hooks/useDarkMode.ts @@ -0,0 +1,23 @@ +import { useCallback, useEffect, useState } from 'react' + +const STORAGE_KEY = 'bytebite-dark' + +// An explicit choice wins; otherwise follow the OS. +function getInitialDark(): boolean { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored !== null) return stored === 'true' + return window.matchMedia('(prefers-color-scheme: dark)').matches +} + +export function useDarkMode() { + const [darkMode, setDarkMode] = useState(getInitialDark) + + useEffect(() => { + document.documentElement.classList.toggle('dark', darkMode) + localStorage.setItem(STORAGE_KEY, String(darkMode)) + }, [darkMode]) + + const toggleDark = useCallback(() => setDarkMode(current => !current), []) + + return { darkMode, toggleDark } +} diff --git a/client/src/hooks/useGroceryLists.ts b/client/src/hooks/useGroceryLists.ts new file mode 100644 index 0000000..dd9947c --- /dev/null +++ b/client/src/hooks/useGroceryLists.ts @@ -0,0 +1,108 @@ +import { useCallback, useState } from 'react' +import { useApi } from '../contexts/authContext' +import { ApiError } from '../lib/api' +import { + apiItemsToGroceryDetail, apiSummaryToGroceryList, detailToGrocerySummary, toGroceryItemPayload, +} from '../lib/mappers' +import type { + ApiGroceryList, ApiGroceryListSummary, EditableItem, GroceryItemDetail, GroceryListSummary, + LlmProvider, LoadStatus, +} from '../types' + +// Owns the grocery-list collection. `merge` lives here rather than with the recipes because the +// list it produces belongs to this collection — the Recipes page only triggers it. +export function useGroceryLists() { + const api = useApi() + const [lists, setLists] = useState([]) + const [status, setStatus] = useState('loading') + + const load = useCallback(async () => { + setStatus('loading') + try { + const data = await api.get('/grocery-list') + setLists(data.map(apiSummaryToGroceryList)) + setStatus('ready') + } catch { + setStatus('error') + } + }, [api]) + + const reset = useCallback(() => { + setLists([]) + setStatus('loading') + }, []) + + const fetchItems = useCallback( + async (id: string): Promise => + apiItemsToGroceryDetail(await api.get(`/grocery-list/${id}`)), + [api] + ) + + const create = useCallback(async (name: string, items: EditableItem[]): Promise => { + try { + const saved = await api.post('/grocery-list', { name, items: items.map(toGroceryItemPayload) }) + setLists(prev => [detailToGrocerySummary(saved), ...prev]) + return true + } catch { + return false + } + }, [api]) + + // Replaces name and items. The PUT reassigns item ids, so the fresh items go back to the view + // for it to reseed its cache; the summary counts are recomputed from the response. + const update = useCallback( + async (id: string, name: string, items: EditableItem[]): Promise => { + try { + const saved = await api.put(`/grocery-list/${id}`, { name, items: items.map(toGroceryItemPayload) }) + setLists(prev => prev.map(list => (list.id === id ? detailToGrocerySummary(saved) : list))) + return apiItemsToGroceryDetail(saved) + } catch { + return null + } + }, + [api] + ) + + const remove = useCallback((id: string) => { + const previous = lists + setLists(prev => prev.filter(list => list.id !== id)) + api.del(`/grocery-list/${id}`).catch(error => { + if (error instanceof ApiError && error.status === 404) return + setLists(previous) + }) + }, [api, lists]) + + // Merges recipes into a brand-new grocery list server-side and prepends it to the history. + const merge = useCallback(async (recipeIds: string[], llmProvider: LlmProvider): Promise => { + try { + const saved = await api.post('/grocery-list/merge', { recipeIds, llmProvider }) + setLists(prev => [detailToGrocerySummary(saved), ...prev]) + return true + } catch { + return false + } + }, [api]) + + // Persists one item's picked-up state and keeps the summary counts in step. Returns false when + // the server rejects it so the view can revert its own optimistic update. + const toggleItem = useCallback( + async (listId: string, itemId: string, purchased: boolean): Promise => { + const adjust = (delta: number) => setLists(prev => prev.map(list => + list.id === listId + ? { ...list, purchasedCount: Math.max(0, Math.min(list.itemCount, list.purchasedCount + delta)) } + : list + )) + adjust(purchased ? 1 : -1) + try { + await api.patch(`/grocery-list/${listId}/items/${itemId}`, { purchased }) + return true + } catch { + adjust(purchased ? -1 : 1) + return false + } + }, + [api] + ) + + return { lists, status, load, reset, fetchItems, create, update, remove, merge, toggleItem } +} diff --git a/client/src/hooks/useLlmProvider.ts b/client/src/hooks/useLlmProvider.ts new file mode 100644 index 0000000..b76fe43 --- /dev/null +++ b/client/src/hooks/useLlmProvider.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react' +import type { LlmProvider } from '../types' + +const STORAGE_KEY = 'bytebite:llmProvider' + +// Shared by the Home page (which generates) and the Recipes page (which merges), so it is held once +// at the top of the app rather than in either page. +export function useLlmProvider() { + const [llmProvider, setLlmProvider] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY) + return stored === 'openai' || stored === 'local' ? stored : 'logos' + }) + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, llmProvider) + }, [llmProvider]) + + return { llmProvider, setLlmProvider } +} diff --git a/client/src/hooks/useRecipes.ts b/client/src/hooks/useRecipes.ts new file mode 100644 index 0000000..94adb54 --- /dev/null +++ b/client/src/hooks/useRecipes.ts @@ -0,0 +1,75 @@ +import { useCallback, useState } from 'react' +import { useApi } from '../contexts/authContext' +import { ApiError } from '../lib/api' +import { apiItemsToIngredients, apiSummaryToRecipe, toRecipeItemPayload } from '../lib/mappers' +import type { + ApiRecipe, ApiRecipeSummary, EditableItem, Ingredient, LoadStatus, RecipeSummary, +} from '../types' + +// Owns the recipe collection: the summaries the list view renders, plus every call that mutates +// them. The page components stay presentational and receive these as props. +export function useRecipes() { + const api = useApi() + const [recipes, setRecipes] = useState([]) + const [status, setStatus] = useState('loading') + + const load = useCallback(async () => { + setStatus('loading') + try { + const data = await api.get('/recipes') + setRecipes(data.map(apiSummaryToRecipe)) + setStatus('ready') + } catch { + setStatus('error') + } + }, [api]) + + // Clears the collection when the session ends, so a second sign-in never shows the first user's data. + const reset = useCallback(() => { + setRecipes([]) + setStatus('loading') + }, []) + + // Items are fetched per recipe, on demand — the summary endpoint does not carry them. + const fetchItems = useCallback( + async (id: string): Promise => apiItemsToIngredients(await api.get(`/recipes/${id}`)), + [api] + ) + + const create = useCallback(async (name: string, items: EditableItem[]): Promise => { + try { + const saved = await api.post('/recipes', { name, items: items.map(toRecipeItemPayload) }) + setRecipes(prev => [apiSummaryToRecipe(saved), ...prev]) + return true + } catch { + return false + } + }, [api]) + + // Replaces name and items. Returns the fresh items so the view can reseed its cached detail. + const update = useCallback( + async (id: string, name: string, items: EditableItem[]): Promise => { + try { + const saved = await api.put(`/recipes/${id}`, { name, items: items.map(toRecipeItemPayload) }) + setRecipes(prev => prev.map(recipe => (recipe.id === id ? apiSummaryToRecipe(saved) : recipe))) + return apiItemsToIngredients(saved) + } catch { + return null + } + }, + [api] + ) + + // Optimistic: drop the recipe now and put it back if the server refuses. A 404 means it was + // already gone, which is the outcome we wanted anyway. + const remove = useCallback((id: string) => { + const previous = recipes + setRecipes(prev => prev.filter(recipe => recipe.id !== id)) + api.del(`/recipes/${id}`).catch(error => { + if (error instanceof ApiError && error.status === 404) return + setRecipes(previous) + }) + }, [api, recipes]) + + return { recipes, status, load, reset, fetchItems, create, update, remove } +} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts new file mode 100644 index 0000000..446c552 --- /dev/null +++ b/client/src/lib/api.ts @@ -0,0 +1,95 @@ +// The single place the client talks to the backend. Everything that used to be repeated at each +// call site — the /api prefix, the bearer header, the response.ok check, JSON parsing — lives here. +// Failures arrive as ApiError, so callers can branch on the status instead of on a bare Error. + +const BASE = '/api' + +export class ApiError extends Error { + readonly status: number + // The `message` the backend sent, when it sent one. Kept separate from Error.message so callers + // can tell a real server explanation apart from our placeholder. + readonly serverMessage: string | undefined + + constructor(status: number, serverMessage?: string) { + super(serverMessage ?? `Request failed (${status})`) + this.name = 'ApiError' + this.status = status + this.serverMessage = serverMessage + } +} + +// Prefers the server's own message ("Invalid credentials") and falls back to ours when the +// response carried none — which is what every form in the app wants to show. +export function errorMessage(error: unknown, fallback: string): string { + return error instanceof ApiError && error.serverMessage ? error.serverMessage : fallback +} + +type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + +type RequestOptions = { + method?: Method + body?: unknown + token?: string +} + +export async function request(path: string, options: RequestOptions = {}): Promise { + const { method = 'GET', body, token } = options + + const headers: Record = {} + if (body !== undefined) headers['Content-Type'] = 'application/json' + if (token) headers.Authorization = `Bearer ${token}` + + const response = await fetch(`${BASE}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }) + + // A 204 (DELETE) and most error responses carry no JSON body, so a parse failure here is + // expected rather than exceptional — the status is what decides success. + const data = await response.json().catch(() => undefined) + + if (!response.ok) { + throw new ApiError(response.status, (data as { message?: string } | undefined)?.message) + } + return data as T +} + +// A 401 normally means the session died and the app should sign out. One endpoint breaks that rule: +// the server answers a wrong *current password* with 401 too (AuthService.updatePassword), and +// mistyping it must not log you out. Those calls opt out with `signOutOn401: false`. +export type CallOptions = { signOutOn401?: boolean } + +export type ApiClient = { + get: (path: string, options?: CallOptions) => Promise + post: (path: string, body?: unknown, options?: CallOptions) => Promise + put: (path: string, body?: unknown, options?: CallOptions) => Promise + patch: (path: string, body?: unknown, options?: CallOptions) => Promise + del: (path: string, options?: CallOptions) => Promise +} + +// Binds the current session to every request. `getToken` is read per call (not captured once) so +// the client identity stays stable across re-renders even as the token is refreshed. +export function createApiClient(config: { getToken: () => string; onUnauthorized: () => void }): ApiClient { + const call = async (path: string, method: Method, body?: unknown, options: CallOptions = {}): Promise => { + const token = config.getToken() + try { + return await request(path, { method, body, token }) + } catch (error) { + // A token can expire during any call, not just the one at boot, so the session is torn down + // here rather than at each call site. A 401 with no token is a rejected login, not an expired + // session, so it is left for the form to report. + const expired = error instanceof ApiError && error.status === 401 && token + if (expired && options.signOutOn401 !== false) config.onUnauthorized() + throw error + } + } + + return { + get: (path, options) => call(path, 'GET', undefined, options), + post: (path, body, options) => call(path, 'POST', body, options), + put: (path, body, options) => call(path, 'PUT', body, options), + patch: (path, body, options) => call(path, 'PATCH', body, options), + del: (path, options) => call(path, 'DELETE', undefined, options), + } +} diff --git a/client/src/lib/mappers.test.ts b/client/src/lib/mappers.test.ts index ebf421d..457a4bb 100644 --- a/client/src/lib/mappers.test.ts +++ b/client/src/lib/mappers.test.ts @@ -73,6 +73,41 @@ describe('toRecipeItemPayload / toGroceryItemPayload', () => { }) }) +// A restricted ingredient is stored as the thing the user should actually buy, so the swap has to +// happen on the way out — everything downstream then sees a perfectly ordinary ingredient. +describe('dietary substitution at save time', () => { + const milk: EditableItem = { name: 'Milk', quantity: '200', unit: 'ml', category: 'DAIRY' } + + it('saves a restricted ingredient under its alternative', () => { + const restricted: EditableItem = { ...milk, restricted: true, alternative: 'oat milk' } + expect(toRecipeItemPayload(restricted).name).toBe('oat milk') + expect(toGroceryItemPayload(restricted).name).toBe('oat milk') + }) + + it('keeps the quantity, unit and category of the ingredient it replaces', () => { + const restricted: EditableItem = { ...milk, restricted: true, alternative: 'oat milk' } + expect(toRecipeItemPayload(restricted)).toEqual({ + name: 'oat milk', quantity: 200, unit: 'ml', category: 'DAIRY', + }) + }) + + it('keeps the original when the model found no alternative', () => { + expect(toRecipeItemPayload({ ...milk, restricted: true, alternative: null }).name).toBe('Milk') + }) + + it('never substitutes an unrestricted ingredient', () => { + // Nothing clashes with the diet, so there is nothing to swap — even if a stale suggestion rides along. + expect(toRecipeItemPayload({ ...milk, restricted: false, alternative: 'oat milk' }).name).toBe('Milk') + expect(toRecipeItemPayload(milk).name).toBe('Milk') + }) + + it('leaves the dietary fields out of the payload entirely', () => { + const payload = toRecipeItemPayload({ ...milk, restricted: true, alternative: 'oat milk' }) + expect(payload).not.toHaveProperty('restricted') + expect(payload).not.toHaveProperty('alternative') + }) +}) + describe('apiItemsToIngredients', () => { it('maps items and formats each quantity', () => { const recipe: ApiRecipe = { diff --git a/client/src/lib/mappers.ts b/client/src/lib/mappers.ts index 551fa6e..8db0b76 100644 --- a/client/src/lib/mappers.ts +++ b/client/src/lib/mappers.ts @@ -23,10 +23,26 @@ export function parseQuantity(quantity: string): number | null { return Number.isFinite(n) ? n : null } +// An ingredient that clashes with the user's diet is stored as its alternative — "milk" on a vegan +// recipe is saved as "oat milk". The swap happens here, at the one boundary every save funnels +// through, so what lands in the database is already the thing the user should buy and nothing +// downstream (the lists, the merge, the shop) has to know about diets at all. +// +// The flags only ever arrive on a freshly generated recipe; a row with no alternative (the model +// found none) or one typed by hand in the editor is saved exactly as written. +function resolveName(item: EditableItem): string { + return item.restricted && item.alternative ? item.alternative : item.name +} + // Maps a form row to the API item payload. Recipes omit `purchased`; grocery lists send the // row's current flag (undefined → false for newly added items) so surviving items stay picked up. export function toRecipeItemPayload(item: EditableItem) { - return { name: item.name, quantity: parseQuantity(item.quantity), unit: item.unit, category: item.category } + return { + name: resolveName(item), + quantity: parseQuantity(item.quantity), + unit: item.unit, + category: item.category, + } } export function toGroceryItemPayload(item: EditableItem) { diff --git a/client/src/main.tsx b/client/src/main.tsx index bef5202..ade9d64 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,10 +1,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' import './index.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/client/src/components/GroceryListView.test.tsx b/client/src/pages/GroceryListsPage.test.tsx similarity index 93% rename from client/src/components/GroceryListView.test.tsx rename to client/src/pages/GroceryListsPage.test.tsx index ba4cced..cc4a9bb 100644 --- a/client/src/components/GroceryListView.test.tsx +++ b/client/src/pages/GroceryListsPage.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, type Mock } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { GroceryListView } from './GroceryListView' +import { GroceryListsPage } from './GroceryListsPage' import type { GroceryListSummary, GroceryItemDetail } from '../types' const list: GroceryListSummary = { @@ -13,7 +13,7 @@ const items: GroceryItemDetail[] = [ { itemId: 'i2', name: 'Eggs', quantity: 'N/A', unit: '', category: 'DAIRY', purchased: false }, ] -function renderView(overrides: Partial> = {}) { +function renderView(overrides: Partial> = {}) { const props = { lists: [list], status: 'ready' as const, @@ -25,11 +25,11 @@ function renderView(overrides: Partial) + render() return props } -describe('GroceryListView load states', () => { +describe('GroceryListsPage load states', () => { it('shows a spinner while loading', () => { renderView({ status: 'loading' }) expect(screen.getByText(/loading your grocery lists/i)).toBeInTheDocument() @@ -54,7 +54,7 @@ describe('GroceryListView load states', () => { }) }) -describe('GroceryListView item loading', () => { +describe('GroceryListsPage item loading', () => { it('lazily fetches items when a card is expanded', async () => { const user = userEvent.setup() const { fetchItems } = renderView() @@ -80,7 +80,7 @@ describe('GroceryListView item loading', () => { }) }) -describe('GroceryListView optimistic toggle', () => { +describe('GroceryListsPage optimistic toggle', () => { it('marks an item purchased immediately and persists it', async () => { const user = userEvent.setup() const { onToggleItem } = renderView() @@ -111,7 +111,7 @@ describe('GroceryListView optimistic toggle', () => { }) }) -describe('GroceryListView actions', () => { +describe('GroceryListsPage actions', () => { it('deletes a list via the delete control', async () => { const user = userEvent.setup() const { onDeleteList } = renderView() diff --git a/client/src/components/GroceryListView.tsx b/client/src/pages/GroceryListsPage.tsx similarity index 98% rename from client/src/components/GroceryListView.tsx rename to client/src/pages/GroceryListsPage.tsx index a86bbab..60c92c0 100644 --- a/client/src/components/GroceryListView.tsx +++ b/client/src/pages/GroceryListsPage.tsx @@ -4,10 +4,9 @@ import { ShoppingCart, ChevronDown, ShoppingBag, Plus, Pencil, Check, Copy, Trash2, CircleCheckBig, X, Loader2, AlertTriangle, } from 'lucide-react' -import type { GroceryListSummary, GroceryItemDetail, EditableItem } from '../types' -import { ItemListForm } from './ItemListForm' +import type { GroceryListSummary, GroceryItemDetail, EditableItem, LoadStatus } from '../types' +import { ItemListForm } from '../components/ItemListForm' -type LoadStatus = 'loading' | 'ready' | 'error' type ItemState = { status: LoadStatus; items: GroceryItemDetail[] } // The modal is either creating a new list or editing an existing one (seeded with its items). @@ -15,7 +14,7 @@ type FormMode = | { kind: 'create' } | { kind: 'edit'; id: string; name: string; items: EditableItem[] } -interface GroceryListViewProps { +interface GroceryListsPageProps { lists: GroceryListSummary[] status: LoadStatus onRetry: () => void @@ -46,9 +45,9 @@ function toEditable(item: GroceryItemDetail): EditableItem { } } -export function GroceryListView({ +export function GroceryListsPage({ lists, status, onRetry, onToggleItem, onDeleteList, onCreateList, onUpdateList, fetchItems, -}: GroceryListViewProps) { +}: GroceryListsPageProps) { const [openId, setOpenId] = useState(null) const [copyState, setCopyState] = useState<{ id: string; ok: boolean } | null>(null) const [itemsById, setItemsById] = useState>({}) diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx new file mode 100644 index 0000000..a06c17c --- /dev/null +++ b/client/src/pages/HomePage.tsx @@ -0,0 +1,29 @@ +import { HeroSection } from '../components/HeroSection' +import { RecipeCard } from '../components/RecipeCard' +import { FeatureCards } from '../components/FeatureCards' +import type { GroceryList, LlmProvider } from '../types' + +interface HomePageProps { + llmProvider: LlmProvider + onLlmProviderChange: (provider: LlmProvider) => void + // A generated dish is saved as a recipe only; grocery lists are created later by merging recipes + // on the Recipes page. Returns true once persisted, so the card can confirm it. + onListGenerated: (list: GroceryList) => Promise +} + +export function HomePage({ llmProvider, onLlmProviderChange, onListGenerated }: HomePageProps) { + return ( + <> + + + +

+ ByteBite · AI-powered grocery assistant +

+ + ) +} diff --git a/client/src/components/AuthCard.test.tsx b/client/src/pages/LoginPage.test.tsx similarity index 57% rename from client/src/components/AuthCard.test.tsx rename to client/src/pages/LoginPage.test.tsx index 2b61375..0f876f2 100644 --- a/client/src/components/AuthCard.test.tsx +++ b/client/src/pages/LoginPage.test.tsx @@ -1,22 +1,41 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { AuthCard, type AuthPayload } from './AuthCard' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { LoginPage } from './LoginPage' +import { AuthProvider } from '../contexts/AuthProvider' +import type { AuthPayload } from '../types' const payload: AuthPayload = { token: 'jwt-123', user: { userId: 'u1', name: 'Ada', email: 'ada@example.com', createdAt: '2026-01-01' }, } -function mockFetchOnce(response: { ok: boolean; body: unknown }) { +function mockFetchOnce(response: { status: number; body: unknown }) { const fetchMock = vi.fn().mockResolvedValue({ - ok: response.ok, + ok: response.status >= 200 && response.status < 300, + status: response.status, json: () => Promise.resolve(response.body), }) vi.stubGlobal('fetch', fetchMock) return fetchMock } +// The page signs in through the auth context and then redirects, so it renders inside the provider +// and a router, with a stand-in for the page it lands on. +function renderLoginPage() { + render( + + + + } /> + Signed in} /> + + + + ) +} + // Both the mode toggle and the form's submit button read "Login", so disambiguate the submit // action by its type attribute. function submitButton() { @@ -26,6 +45,7 @@ function submitButton() { } beforeEach(() => { + localStorage.clear() vi.restoreAllMocks() }) @@ -33,18 +53,18 @@ afterEach(() => { vi.unstubAllGlobals() }) -describe('AuthCard', () => { - it('logs in and hands the payload back to the parent', async () => { +describe('LoginPage', () => { + it('logs in, stores the session, and redirects into the app', async () => { const user = userEvent.setup() - const fetchMock = mockFetchOnce({ ok: true, body: payload }) - const onAuthenticated = vi.fn() - render() + const fetchMock = mockFetchOnce({ status: 200, body: payload }) + renderLoginPage() await user.type(screen.getByLabelText('Email'), 'ada@example.com') await user.type(screen.getByLabelText('Password'), 'supersecret') await user.click(submitButton()) - await waitFor(() => expect(onAuthenticated).toHaveBeenCalledWith(payload)) + expect(await screen.findByText('Signed in')).toBeInTheDocument() + expect(localStorage.getItem('bytebite-token')).toBe('jwt-123') const [url, init] = fetchMock.mock.calls[0] expect(url).toBe('/api/auth/login') @@ -53,8 +73,8 @@ describe('AuthCard', () => { it('sends the name field and the register endpoint in register mode', async () => { const user = userEvent.setup() - const fetchMock = mockFetchOnce({ ok: true, body: payload }) - render() + const fetchMock = mockFetchOnce({ status: 200, body: payload }) + renderLoginPage() await user.click(screen.getByRole('button', { name: /register/i })) await user.type(screen.getByLabelText('Name'), 'Ada') @@ -68,17 +88,19 @@ describe('AuthCard', () => { expect(JSON.parse(init.body)).toEqual({ name: 'Ada', email: 'ada@example.com', password: 'supersecret' }) }) - it("surfaces the server's error message and does not authenticate", async () => { + it("surfaces the server's error message and stays on the login page", async () => { const user = userEvent.setup() - mockFetchOnce({ ok: false, body: { message: 'Invalid credentials' } }) - const onAuthenticated = vi.fn() - render() + // Rejected credentials answer 401 too. With no session to tear down, that must simply surface + // the message rather than trip the api client's sign-out-on-401. + mockFetchOnce({ status: 401, body: { message: 'Invalid credentials' } }) + renderLoginPage() await user.type(screen.getByLabelText('Email'), 'ada@example.com') await user.type(screen.getByLabelText('Password'), 'wrongpass1') await user.click(submitButton()) expect(await screen.findByText('Invalid credentials')).toBeInTheDocument() - expect(onAuthenticated).not.toHaveBeenCalled() + expect(screen.queryByText('Signed in')).not.toBeInTheDocument() + expect(localStorage.getItem('bytebite-token')).toBeNull() }) -}) \ No newline at end of file +}) diff --git a/client/src/components/AuthCard.tsx b/client/src/pages/LoginPage.tsx similarity index 85% rename from client/src/components/AuthCard.tsx rename to client/src/pages/LoginPage.tsx index c49f0c0..711bbaf 100644 --- a/client/src/components/AuthCard.tsx +++ b/client/src/pages/LoginPage.tsx @@ -1,27 +1,17 @@ import { useState } from 'react' import { motion } from 'framer-motion' +import { Navigate, useLocation } from 'react-router-dom' import { LogIn, Loader2, UserPlus, Utensils } from 'lucide-react' -import { AlertBanner } from './AlertBanner' - -export type AuthUser = { - userId: string - name: string - email: string - createdAt: string -} - -export type AuthPayload = { - token: string - user: AuthUser -} +import { AlertBanner } from '../components/AlertBanner' +import { useAuth } from '../contexts/authContext' +import { errorMessage } from '../lib/api' +import type { AuthPayload } from '../types' type Mode = 'login' | 'register' -interface AuthCardProps { - onAuthenticated: (payload: AuthPayload) => void -} - -export function AuthCard({ onAuthenticated }: AuthCardProps) { +export function LoginPage() { + const { token, user, signIn, api } = useAuth() + const location = useLocation() const [mode, setMode] = useState('login') const [name, setName] = useState('') const [email, setEmail] = useState('') @@ -42,25 +32,25 @@ export function AuthCard({ onAuthenticated }: AuthCardProps) { setLoading(true) try { - const response = await fetch(isRegister ? '/api/auth/register' : '/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(isRegister ? { name, email, password } : { email, password }), - }) - - const data = await response.json() - if (!response.ok) { - throw new Error(data.message || 'Authentication failed.') - } - - onAuthenticated(data as AuthPayload) + const payload = await api.post( + isRegister ? '/auth/register' : '/auth/login', + isRegister ? { name, email, password } : { email, password } + ) + signIn(payload) } catch (err) { - setError(err instanceof Error ? err.message : 'Authentication failed.') + setError(errorMessage(err, 'Authentication failed.')) } finally { setLoading(false) } } + // Signing in flips the session, which re-renders us straight into the app — back to the page the + // guard bounced us from, or Home. This also covers an authenticated user opening /login by hand. + if (token && user) { + const from = (location.state as { from?: string } | null)?.from + return + } + return (
) { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const key = `${(init?.method ?? 'GET').toUpperCase()} ${String(input)}` + const reply = routes[key] + if (!reply) throw new Error(`No mock registered for ${key}`) + return { + ok: reply.status >= 200 && reply.status < 300, + status: reply.status, + json: async () => reply.body, + } as unknown as Response + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +function renderProfile() { + render( + + + + + + ) +} + +beforeEach(() => { + localStorage.clear() + localStorage.setItem('bytebite-token', auth.token) + localStorage.setItem('bytebite-user', JSON.stringify(auth.user)) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('ProfilePage', () => { + it('saves name and email, and adopts the re-issued token', async () => { + const user = userEvent.setup() + const updated: AuthPayload = { + token: 'jwt-2', + user: { ...auth.user, name: 'Ada Lovelace' }, + } + const fetchMock = installApi({ + 'GET /api/users/me': { status: 200, body: auth }, + 'PATCH /api/users/me': { status: 200, body: updated }, + }) + renderProfile() + + const nameField = screen.getByDisplayValue('Ada') + await user.clear(nameField) + await user.type(nameField, 'Ada Lovelace') + await user.click(screen.getByRole('button', { name: /save changes/i })) + + expect(await screen.findByText('Profile updated.')).toBeInTheDocument() + + // The JWT embeds the name, so the server re-issues it and the new one replaces the session. + await waitFor(() => expect(localStorage.getItem('bytebite-token')).toBe('jwt-2')) + const patch = fetchMock.mock.calls.find(([, init]) => (init as RequestInit)?.method === 'PATCH')! + expect(JSON.parse((patch[1] as RequestInit).body as string)) + .toEqual({ name: 'Ada Lovelace', email: 'ada@example.com' }) + }) + + it('reports a wrong current password without ending the session', async () => { + const user = userEvent.setup() + // The backend answers a wrong current password with 401 — the same status an expired token + // produces. Mistyping it must show the message, not sign the user out. + installApi({ + 'GET /api/users/me': { status: 200, body: auth }, + 'PUT /api/users/me/password': { status: 401, body: { message: 'Current password is incorrect.' } }, + }) + renderProfile() + + await user.type(screen.getByLabelText('Current password'), 'wrongpass1') + await user.type(screen.getByLabelText('New password'), 'newpassword1') + await user.click(screen.getByRole('button', { name: /change password/i })) + + expect(await screen.findByText('Current password is incorrect.')).toBeInTheDocument() + expect(localStorage.getItem('bytebite-token')).toBe('jwt-1') + expect(screen.getByRole('heading', { name: 'Profile' })).toBeInTheDocument() + }) + + it('signs the user out after a successful password change', async () => { + const user = userEvent.setup() + installApi({ + 'GET /api/users/me': { status: 200, body: auth }, + 'PUT /api/users/me/password': { status: 204 }, + }) + renderProfile() + + await user.type(screen.getByLabelText('Current password'), 'supersecret') + await user.type(screen.getByLabelText('New password'), 'newpassword1') + await user.click(screen.getByRole('button', { name: /change password/i })) + + expect(await screen.findByText(/please sign in again/i)).toBeInTheDocument() + // The sign-out is deferred so the confirmation is readable first. + await waitFor( + () => expect(localStorage.getItem('bytebite-token')).toBeNull(), + { timeout: 3000 } + ) + }) +}) diff --git a/client/src/components/ProfileView.tsx b/client/src/pages/ProfilePage.tsx similarity index 79% rename from client/src/components/ProfileView.tsx rename to client/src/pages/ProfilePage.tsx index 470bf7b..544e23f 100644 --- a/client/src/components/ProfileView.tsx +++ b/client/src/pages/ProfilePage.tsx @@ -1,16 +1,10 @@ import { useState } from 'react' import { motion } from 'framer-motion' import { Loader2, Save, KeyRound } from 'lucide-react' -import { AlertBanner } from './AlertBanner' -import type { AuthUser } from './AuthCard' - -interface ProfileViewProps { - user: AuthUser - // Updates name/email. Returns null on success, or an error message to display. - onUpdateProfile: (name: string, email: string) => Promise - // Changes the password. Returns null on success (caller then logs out), or an error message. - onChangePassword: (currentPassword: string, newPassword: string) => Promise -} +import { AlertBanner } from '../components/AlertBanner' +import { useAuth } from '../contexts/authContext' +import { errorMessage } from '../lib/api' +import type { AuthPayload } from '../types' const inputCls = 'w-full rounded-2xl border border-gray-200 dark:border-gray-700 bg-gray-50/70 dark:bg-gray-950/50 px-4 py-3 text-sm text-gray-900 dark:text-white outline-none focus:border-[#2d6a4f]/70 focus:ring-2 focus:ring-[#2d6a4f]/15' @@ -20,9 +14,10 @@ const cardCls = const buttonCls = 'w-full flex items-center justify-center gap-2.5 px-6 py-3.5 rounded-full bg-gradient-to-r from-[#1b5e38] to-[#2d6a4f] text-white font-semibold text-sm shadow-lg shadow-green-900/25 disabled:opacity-60 disabled:cursor-not-allowed' -export function ProfileView({ user, onUpdateProfile, onChangePassword }: ProfileViewProps) { - const [name, setName] = useState(user.name) - const [email, setEmail] = useState(user.email) +export function ProfilePage() { + const { user, api, signIn, signOut } = useAuth() + const [name, setName] = useState(user?.name ?? '') + const [email, setEmail] = useState(user?.email ?? '') const [profileError, setProfileError] = useState('') const [profileSuccess, setProfileSuccess] = useState('') const [savingProfile, setSavingProfile] = useState(false) @@ -33,6 +28,9 @@ export function ProfileView({ user, onUpdateProfile, onChangePassword }: Profile const [passwordSuccess, setPasswordSuccess] = useState('') const [savingPassword, setSavingPassword] = useState(false) + // The route guard only mounts this page for a signed-in user; the check keeps TypeScript happy. + if (!user) return null + const profileDirty = name.trim() !== user.name || email.trim() !== user.email const handleProfileSubmit = async (event: React.FormEvent) => { @@ -40,12 +38,14 @@ export function ProfileView({ user, onUpdateProfile, onChangePassword }: Profile setProfileError('') setProfileSuccess('') setSavingProfile(true) - const error = await onUpdateProfile(name.trim(), email.trim()) - setSavingProfile(false) - if (error) { - setProfileError(error) - } else { + try { + // The JWT embeds name/email, so the server re-issues it; the response is a whole new session. + signIn(await api.patch('/users/me', { name: name.trim(), email: email.trim() })) setProfileSuccess('Profile updated.') + } catch (error) { + setProfileError(errorMessage(error, 'Failed to update profile.')) + } finally { + setSavingProfile(false) } } @@ -54,13 +54,16 @@ export function ProfileView({ user, onUpdateProfile, onChangePassword }: Profile setPasswordError('') setPasswordSuccess('') setSavingPassword(true) - const error = await onChangePassword(currentPassword, newPassword) - // On success the app logs the user out; show a brief confirmation in the meantime. - if (error) { + try { + // A wrong current password also answers 401, so this call must not trip the session teardown. + await api.put('/users/me/password', { currentPassword, newPassword }, { signOutOn401: false }) + // Stay in the saving state and pause briefly so the confirmation is readable before the + // sign-out drops us back to the login screen. + setPasswordSuccess('Password changed, please sign in again.') + setTimeout(signOut, 1200) + } catch (error) { setSavingPassword(false) - setPasswordError(error) - } else { - setPasswordSuccess('Password changed — please sign in again.') + setPasswordError(errorMessage(error, 'Failed to change password.')) } } diff --git a/client/src/components/RecipeListView.tsx b/client/src/pages/RecipesPage.tsx similarity index 96% rename from client/src/components/RecipeListView.tsx rename to client/src/pages/RecipesPage.tsx index 3d08619..4646867 100644 --- a/client/src/components/RecipeListView.tsx +++ b/client/src/pages/RecipesPage.tsx @@ -4,11 +4,10 @@ import { ChefHat, ChevronDown, BookOpen, Plus, Pencil, Check, Copy, Trash2, X, Loader2, AlertTriangle, Combine, } from 'lucide-react' -import type { RecipeSummary, Ingredient, EditableItem } from '../types' -import { AlertBanner } from './AlertBanner' -import { ItemListForm } from './ItemListForm' +import type { RecipeSummary, Ingredient, EditableItem, LoadStatus } from '../types' +import { AlertBanner } from '../components/AlertBanner' +import { ItemListForm } from '../components/ItemListForm' -type LoadStatus = 'loading' | 'ready' | 'error' type ItemState = { status: LoadStatus; items: Ingredient[] } // The modal is either creating a new recipe or editing an existing one (seeded with its items). @@ -16,7 +15,7 @@ type FormMode = | { kind: 'create' } | { kind: 'edit'; id: string; name: string; items: EditableItem[] } -interface RecipeListViewProps { +interface RecipesPageProps { recipes: RecipeSummary[] status: LoadStatus onRetry: () => void @@ -40,9 +39,9 @@ function toEditable(item: Ingredient): EditableItem { return { name: item.name, quantity: item.quantity === 'N/A' ? '' : item.quantity, unit: item.unit, category: item.category } } -export function RecipeListView({ +export function RecipesPage({ recipes, status, onRetry, onDeleteRecipe, onCreateRecipe, onUpdateRecipe, fetchItems, onMerge, -}: RecipeListViewProps) { +}: RecipesPageProps) { const [openId, setOpenId] = useState(null) const [copyState, setCopyState] = useState<{ id: string; ok: boolean } | null>(null) const [itemsById, setItemsById] = useState>({}) @@ -61,9 +60,10 @@ export function RecipeListView({ }) } - // Merges the selected recipes (needs at least two) into a new grocery list. + // Merges the selected recipes into a new grocery list. One is enough — the backend accepts a + // single recipe, and turning one recipe into a shopping list is the app's main promise. const handleMerge = async () => { - if (selected.size < 2 || merging) return + if (selected.size === 0 || merging) return setMerging(true) setMergeResult(null) const ok = await onMerge([...selected]) @@ -220,12 +220,12 @@ export function RecipeListView({

{selected.size === 0 - ? 'Select recipes to merge into a grocery list' + ? 'Select one or more recipes to merge into a grocery list' : `${selected.size} selected`}