diff --git a/README.md b/README.md index 2cf44ca..1b207e9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Repository for team IT2810-H26-T27 i IT2810-H26. -> **Status: Første underveisinnlevering.** Dette er en spesifikasjon og et første designutkast. Ingenting er implementert ennå, og alt kan endres. Vi tar gjerne imot tilbakemeldinger på idé, data, flyt og design. +> **Status: Andre underveisinnlevering.** Backend med GraphQL (Apollo Server) og PostgreSQL er satt opp, og varer fra Kassalapp importeres til databasen. Frontend henter og viser varer via Apollo Client. Handlelister kan opprettes og fylles med varer gjennom API-et. Se [Kjøre prosjektet lokalt](#kjøre-prosjektet-lokalt) for oppsett. ## Idé @@ -38,31 +38,37 @@ Handlelappen er en digital handleliste. Du søker opp dagligvarer eller blar i k Designet er laget i Figma. Disse skjermbildene er et første utkast og kan endre seg. ### Hjem og søk + Forsiden har søkefelt, kategorier og den aktive lappen. På søkesiden kan man filtrere på kategori, pris og merking (økologisk, laktosefri, norskprodusert) og sortere resultatene. Varer legges rett på lappen fra resultatlista, og lappen vises ved siden av. ![Hjem og søk](docs/design/hjem-og-sok.png) #### Søk og filtrering -Hva du får opp når du filtrerer på en bestemt butikk hvis den har produktet og hvis den ikke har produktet. + +Hva du får opp når du filtrerer på en bestemt butikk hvis den har produktet og hvis den ikke har produktet. ![Søk og filtrering](docs/design/sok-filtrert-pa-butikk.png) ### Produktside og meny + Produktsiden viser pris, kilopris, ingredienser, allergener og næringsinnhold. Her kan man endre antall og legge til et notat, for eksempel «skivet, hvis de har». Menyen gir tilgang til alle delene av appen. ![Produktside og meny](docs/design/produkt-og-meny.png) ### Mine handlelister og aktiv liste + Man kan ha flere aktive lister, og den man bruker nå er merket. På den aktive lista kan man endre antall, fjerne varer, sortere etter butikkavdeling og se estimert sum. ![Handlelister](docs/design/handlelister.png) ### Tidligere handlelister + Ferdige lister er gruppert etter måned. Man kan søke i dem og laste inn eldre lister. Fra en tidligere liste kan man bruke den på nytt eller legge varene på den aktive lappen. ![Tidligere lister](docs/design/tidligere-lister.png) ### Handletur (mobil) + Handleturen er laget for mobil, siden den brukes i butikken. Varene er gruppert etter avdeling, og en fremdriftslinje viser hvor mange som er krysset av. ![Handletur på mobil](docs/design/handletur-mobil.png) @@ -99,14 +105,92 @@ ListItem (id, listId, productId, quantity, note, checked) Handlelister, varer på lista, antall, notater og avkrysninger er de brukergenererte dataene. Appen har ingen innlogging. Hver nettleser får en anonym bruker-ID som lagres lokalt og sendes med til backend. -## GraphQL-API (utkast) +## GraphQL-API + +Backenden kjører på port 3001. Når backenden kjører lokalt, kan du åpne http://localhost:3001/ i nettleseren og teste spørringene i Apollo Sandbox. + +### Implementert til innlevering 2 + +| Type | Navn | Hva den gjør | +| -------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Query | `products(search, category, limit = 20, offset = 0)` | Søker i varenavn uten å skille mellom store og små bokstaver (`ILIKE`). Kan filtreres på kategori og pagineres med `limit` og `offset`. Sortert på navn. | +| Query | `product(ean)` | Henter én vare med strekkode. | +| Query | `categories` | Henter alle kategorier, sortert på navn. | +| Query | `shoppingList(id)` | Henter en handleliste med varene på den. | +| Mutation | `createShoppingList(name)` | Lager en ny handleliste. | +| Mutation | `addItemToList(listId, ean, quantity = 1)` | Legger en vare på lista. Ligger varen der fra før, økes antallet. | + +En `Product` har feltene `ean`, `name`, `brand`, `imageUrl`, `categoryId`, `category` og `prices`. Hver pris har `storeCode`, `price`, `unitPrice` og `url`. + +**Eksempel: søk etter melk** + +```graphql +query { + products(search: "melk", limit: 5) { + ean + name + brand + prices { + storeCode + price + } + } +} +``` + +**Eksempel: lag en liste og legg til en vare** + +```graphql +mutation { + createShoppingList(name: "Fredagshandel") { + id + } +} +``` + +```graphql +mutation { + addItemToList(listId: "1", ean: "7041013200110", quantity: 2) { + name + items { + ean + quantity + } + } +} +``` + +Bytt ut `listId` med id-en du fikk tilbake, og `ean` med en strekkode fra `products`. + +Du kan også teste fra terminalen: + +```bash +curl -s http://localhost:3001/ \ + -H "Content-Type: application/json" \ + -d '{"query":"{ products(search: \"melk\", limit: 3) { name } }"}' +``` + +### Planlagt API (utkast) + +Dette er API-et vi har planlagt videre. Det kan endre seg underveis. ```graphql type Query { - products(search: String, filter: ProductFilter, sort: ProductSort, first: Int!, after: String): ProductConnection! + products( + search: String + filter: ProductFilter + sort: ProductSort + first: Int! + after: String + ): ProductConnection! product(id: ID!): Product categories: [Category!]! - shoppingLists(status: ListStatus, search: String, first: Int, after: String): ShoppingListConnection! + shoppingLists( + status: ListStatus + search: String + first: Int + after: String + ): ShoppingListConnection! shoppingList(id: ID!): ShoppingList } @@ -114,41 +198,58 @@ type Mutation { createShoppingList(name: String!): ShoppingList! setListInUse(listId: ID!): ShoppingList! addToList(listId: ID!, productId: ID!, quantity: Int = 1): ListItem! - updateListItem(itemId: ID!, quantity: Int, note: String, checked: Boolean): ListItem! + updateListItem( + itemId: ID! + quantity: Int + note: String + checked: Boolean + ): ListItem! removeFromList(itemId: ID!): ShoppingList! finishShopping(listId: ID!): ShoppingList! - reuseList(listId: ID!): ShoppingList! # ny aktiv liste med samme varer + reuseList(listId: ID!): ShoppingList! # ny aktiv liste med samme varer copyItemsToList(fromListId: ID!, toListId: ID!): ShoppingList! deleteShoppingList(listId: ID!): Boolean! } -input ProductFilter { categoryIds: [ID!], priceMin: Float, priceMax: Float, labels: [String!] } -enum ProductSort { RELEVANCE, NAME_ASC, NAME_DESC, PRICE_ASC, PRICE_DESC, UNIT_PRICE_ASC } +input ProductFilter { + categoryIds: [ID!] + priceMin: Float + priceMax: Float + labels: [String!] +} +enum ProductSort { + RELEVANCE + NAME_ASC + NAME_DESC + PRICE_ASC + PRICE_DESC + UNIT_PRICE_ASC +} ``` Lister med mange elementer, som søkeresultater og tidligere handlelister, returnerer én side om gangen med totalt antall og en markør (cursor) for neste side. ## Hvordan vi oppfyller kravene -| Krav | Planlagt løsning | -| --- | --- | -| Søk | Søkefelt med knapp og debounce, så vi ikke sender et kall for hver tast. Søket skiller ikke mellom store og små bokstaver og håndterer æ, ø og å. Man kan også bla i kategorier. | -| Store resultatsett | Paginering på backend. Klienten henter én side av gangen og aldri hele datasettet. Tidligere lister lastes på samme måte med «Last inn eldre lister». | -| Detaljer | Egen produktside med pris, kilopris, ingredienser, allergener og næringsinnhold. | -| Sortering og filtrering | Gjøres i databasen, så det gjelder hele resultatsettet og ikke bare det som er lastet inn. Viste filtre har antall treff, og man kan nullstille dem. | -| Brukergenererte data | Handlelister, antall, notater og avkrysninger lagres i databasen på VM-en. | -| Tilgjengelighet | Semantisk HTML, tastaturnavigasjon, synlig fokus, labels på skjemafelt og god nok kontrast (WCAG 2.1 AA). Avkrysning vises med mer enn farge (hake og overstreking). Vi tester med Lighthouse, axe og skjermleser. | -| Bærekraft | Bilder er skrudd av i resultatlista som standard («Vis bilder»), og når de vises, er de små og lastes lazy. Data hentes fra Kassalapp med en import og ikke ved hvert søk. Spørringene henter bare feltene som vises, og vi bruker få og lette biblioteker. | +| Krav | Planlagt løsning | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Søk | Søkefelt med knapp og debounce, så vi ikke sender et kall for hver tast. Søket skiller ikke mellom store og små bokstaver og håndterer æ, ø og å. Man kan også bla i kategorier. | +| Store resultatsett | Paginering på backend. Klienten henter én side av gangen og aldri hele datasettet. Tidligere lister lastes på samme måte med «Last inn eldre lister». | +| Detaljer | Egen produktside med pris, kilopris, ingredienser, allergener og næringsinnhold. | +| Sortering og filtrering | Gjøres i databasen, så det gjelder hele resultatsettet og ikke bare det som er lastet inn. Viste filtre har antall treff, og man kan nullstille dem. | +| Brukergenererte data | Handlelister, antall, notater og avkrysninger lagres i databasen på VM-en. | +| Tilgjengelighet | Semantisk HTML, tastaturnavigasjon, synlig fokus, labels på skjemafelt og god nok kontrast (WCAG 2.1 AA). Avkrysning vises med mer enn farge (hake og overstreking). Vi tester med Lighthouse, axe og skjermleser. | +| Bærekraft | Bilder er skrudd av i resultatlista som standard («Vis bilder»), og når de vises, er de små og lastes lazy. Data hentes fra Kassalapp med en import og ikke ved hvert søk. Spørringene henter bare feltene som vises, og vi bruker få og lette biblioteker. | ## Teknologi -| Del | Valg | Hvorfor | -| --- | --- | --- | -| Frontend | React, TypeScript og Vite | Kravet i oppgaven | -| State | Apollo Client | Cache for data fra GraphQL og lokal state som filtervalg og aktiv liste | -| Backend | Node, TypeScript og Apollo Server | Egen GraphQL-backend på port 3001 | -| Database | PostgreSQL direkte på VM-en | Relasjonelle data (varer, priser, lister) og godt søk og indekser | -| Kvalitet | ESLint, Prettier, Vitest og Playwright | Lint, komponenttester, ende-til-ende-tester og tester av API-et | +| Del | Valg | Hvorfor | +| -------- | -------------------------------------- | ----------------------------------------------------------------------- | +| Frontend | React, TypeScript og Vite | Kravet i oppgaven | +| State | Apollo Client | Cache for data fra GraphQL og lokal state som filtervalg og aktiv liste | +| Backend | Node, TypeScript og Apollo Server | Egen GraphQL-backend på port 3001 | +| Database | PostgreSQL direkte på VM-en | Relasjonelle data (varer, priser, lister) og godt søk og indekser | +| Kvalitet | ESLint, Prettier, Vitest og Playwright | Lint, komponenttester, ende-til-ende-tester og tester av API-et | ## Plan for innleveringene @@ -157,19 +258,231 @@ Lister med mange elementer, som søkeresultater og tidligere handlelister, retur 3. Komplett beta med paginering, filtrering, sortering, handlelister og handletur, pluss tilgjengelighet og bærekraft. 4. Tester, opprydding og dokumentasjon. +## Status + +**Ferdig til innlevering 2** + +- PostgreSQL-skjema med tabeller for kategorier, varer, priser og handlelister, med indekser for søk og filtrering. +- Import fra Kassalapp til en JSON-dump (`npm run import`), og en seed som fyller databasen fra dumpen uten API-nøkkel (`npm run seed`). +- GraphQL-backend med Apollo Server: søk, kategorifilter og paginering med `limit` og `offset`, enkeltvare, kategorier og handlelister. Se [GraphQL-API](#graphql-api). +- Frontend med React og Apollo Client som henter og viser varer med laveste pris. Søket bruker debounce (400 ms), så det sendes ikke et kall for hvert tastetrykk. +- Backend og database på VM-en (pågår, se [Kjøre på VM-en](#kjøre-på-vm-en)). + +**Kommer i innlevering 3** + +- Paginering i klienten («last flere» eller sider) med totalt antall treff. +- Filtrering på pris, butikk og merking, og sortering på pris og kilopris, i databasen. +- Produktside med ingredienser, allergener og næringsinnhold. +- Handlelister i grensesnittet: opprette, legge til varer, endre antall, handletur med avkrysning og tidligere lister. +- Tilgjengelighet og bærekraft som beskrevet i [Hvordan vi oppfyller kravene](#hvordan-vi-oppfyller-kravene). +- Prettier, Vitest og Playwright. + ## Bruk av KI README-en til første innlevering er skrevet med hjelp fra Claude (KI), basert på gruppas egen skjermflyt, design, møtereferater og valg av datakilde. Gruppa har gått gjennom innholdet. Vi fortsetter å dokumentere bruken av KI her gjennom prosjektet. ## Kjøre prosjektet lokalt +### Krav + +- **Node.js 22.12 eller nyere** og npm. Vite 8 krever minst denne versjonen. Sjekk med `node -v`. +- **PostgreSQL 14 eller nyere** med utvidelsen `pg_trgm`, som brukes til søk. Den følger med vanlige installasjoner, for eksempel Homebrew på Mac og pakken `postgresql-contrib` på Ubuntu. +- **Git**. +- **Den fulle datadumpen** `kassalapp-dump-full.json` (ca. 200 MB, 46 853 varer). Den er for stor for git og lastes ned separat, se steg 4. Filen `backend/data/kassalapp-dump.json` i repoet har bare 199 testvarer. +- **Kassalapp API-nøkkel** trengs bare hvis du skal hente nye data fra API-et. Du trenger den ikke for å sette opp databasen. + +### 1. Klon repoet + +```bash +git clone git@git.ntnu.no:IT2810-H26/T27-Project-2.git +cd T27-Project-2 +git switch dev +``` + +Vi jobber på `dev`. `main` oppdateres bare ved innleveringene. + +### 2. Installer avhengigheter + +Frontend og backend har hver sin `package.json`, så du må installere begge: + +```bash +cd backend && npm install && cd .. +cd frontend && npm install && cd .. +``` + +Kjør `npm install` på nytt i mappa det gjelder hvis noen har lagt til nye pakker. Feilen `Failed to resolve import` i nettleseren betyr som regel at du mangler en pakke. + +### 3. Miljøvariabler + +Begge mappene har en `.env.example`. Kopier den til `.env` og fyll inn verdiene. `.env`-filene er ignorert av git og skal aldri sjekkes inn. + +```bash +cp backend/.env.example backend/.env +cp frontend/.env.example frontend/.env +``` + +**`backend/.env`** + +| Variabel | Eksempel | Beskrivelse | +| ------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `PORT` | `3001` | Porten GraphQL-serveren kjører på. | +| `DATABASE_URL` | `postgres://localhost:5432/handlelapp` | Tilkobling til PostgreSQL. Legg til brukernavn og passord om databasen krever det: `postgres://bruker:passord@localhost:5432/handlelapp`. | +| `KASSALAPP_API_KEY` | | Brukes bare av `npm run import`. Kan stå tom ellers. | + +**`frontend/.env`** + +| Variabel | Eksempel | Beskrivelse | +| ------------------ | ------------------------ | ---------------------------------------------------------------------------- | +| `VITE_GRAPHQL_URL` | `http://localhost:3001/` | Adressen til GraphQL-backenden. Uten denne viser frontend bare en hvit side. | + +Vite leser `.env` bare ved oppstart, så start frontend på nytt etter at du har endret den. + +### 4. Sett opp databasen + +Gjør dette før du starter backenden. Kommandoene kjøres fra `backend/`. + +**a) Lag databasen og kjør skjemaet** + +Start PostgreSQL først. + +```bash +cd backend +createdb handlelapp +psql handlelapp -f db/migrations/001_init.sql +``` + +`001_init.sql` lager tabellene `categories`, `products`, `prices`, `shopping_lists` og `list_items`, med indekser for søk, filtrering og sortering. Skjemaet bruker `IF NOT EXISTS` og kan kjøres flere ganger. + +**b) Hent den fulle datadumpen** +Filen `data/kassalapp-dump.json` i repoet har bare 199 testvarer. For å få hele datasettet: + +1. Last ned [kassalapp-dump-full.json.zip](https://studntnu-my.sharepoint.com/:u:/r/personal/annisaam_ntnu_no/Documents/Semester%203/IT2810/Prosjekt%202/kassalapp-dump-full.json.zip?d=wf8e8319b6fc9450fa68034f474d85e48&csf=1&web=1&e=ht0XnP) og pakk den ut. Lenken krever NTNU-pålogging. Har du ikke tilgang, fungerer appen også med testdumpen (199 varer), eller du kan bruke versjonen på VM-en, som har alle dataene. +2. Kopier filen over testdumpen. Bytt ut `~/Downloads` med mappa du lagret den i: + + ```bash + cp ~/Downloads/kassalapp-dump-full.json data/kassalapp-dump.json + ``` + +3. Be git ignorere den lokale endringen, så filen ikke blir committet ved et uhell: + + ```bash + git update-index --skip-worktree data/kassalapp-dump.json + ``` + + Hvis du senere må hente en ny versjon av testdumpen fra git, slår du det av igjen med `git update-index --no-skip-worktree data/kassalapp-dump.json`. + +> **Ikke commit `data/kassalapp-dump.json` etter at du har byttet den ut.** Bruk `git add ` i stedet for `git add .`, og sjekk `git status` før du committer. + +**c) Fyll databasen** + +```bash +npm run seed +``` + +Med den fulle dumpen tar dette noen minutter. Ikke avbryt. Seeden kan kjøres flere ganger, siden eksisterende varer og priser oppdateres i stedet for å dupliseres. Den må kjøres fra `backend/`, fordi filstien er relativ. + +**d) Sjekk at du har alle dataene** + +```bash +psql handlelapp -c "SELECT (SELECT COUNT(*) FROM products) AS varer, (SELECT COUNT(*) FROM prices) AS priser, (SELECT COUNT(*) FROM categories) AS kategorier;" +``` + +Med den fulle dumpen skal du få **46 853 varer, 67 132 priser og 1 299 kategorier**. Med testdumpen får du 199 varer. Får du flere priser enn 67 132, si fra i gruppa før du gjør noe mer. + +### 5. Kjør backend og frontend + +Backend og frontend må kjøre samtidig, i hver sin terminal. + +**Terminal 1, backend:** + +```bash +cd backend +npm run dev +``` + +Serveren starter på http://localhost:3001/. Der kan du også teste spørringer i Apollo Sandbox. + +**Terminal 2, frontend:** + ```bash cd frontend -npm install npm run dev ``` -Instruksjoner for backend og database kommer til andre innlevering. +Åpne http://localhost:5173/ i nettleseren. Stopp serverne med `Ctrl + C`. + +### Hente nye data fra Kassalapp (valgfritt) + +> **Ikke kjør dette for å sette opp prosjektet.** Importen henter alt fra API-et på nytt, tar flere timer og overskriver `data/kassalapp-dump.json`. Bruk den fulle dumpen fra steg 4 i stedet. + +Dette trengs bare hvis gruppa har bestemt at dataene skal oppdateres. Legg inn `KASSALAPP_API_KEY` i `backend/.env` og kjør: + +```bash +cd backend +npm run import # henter fra Kassalapp og skriver data/kassalapp-dump.json +npm run seed # legger de nye dataene inn i databasen +``` + +Gratisnøkkelen tillater 60 kall i minuttet, så importen tar litt tid. + +### Skript + +| Mappe | Kommando | Hva den gjør | +| ----------- | ----------------- | --------------------------------------------------------- | +| `backend/` | `npm run dev` | Starter GraphQL-serveren og restarter ved endringer. | +| `backend/` | `npm run build` | Kompilerer TypeScript til `dist/`. | +| `backend/` | `npm start` | Kjører den kompilerte serveren fra `dist/`. | +| `backend/` | `npm run import` | Henter data fra Kassalapp til `data/kassalapp-dump.json`. | +| `backend/` | `npm run seed` | Fyller databasen fra dumpen. | +| `frontend/` | `npm run dev` | Starter utviklingsserveren til Vite. | +| `frontend/` | `npm run build` | Typesjekker og bygger frontend til `dist/`. | +| `frontend/` | `npm run lint` | Kjører ESLint. | +| `frontend/` | `npm run preview` | Viser det bygde resultatet lokalt. | + +### Vanlige feil + +| Feil | Løsning | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Hvit side i nettleseren | `frontend/.env` mangler. Se steg 3, og start frontend på nytt. | +| `Failed to resolve import "..."` | Kjør `npm install` i `frontend/`. | +| `EADDRINUSE` eller «Port 5173 is in use» | En gammel server kjører fortsatt. Stopp den med `Ctrl + C`, eller kjør `lsof -ti :3001 :5173 \| xargs kill`. | +| `DATABASE_URL mangler` | `backend/.env` mangler. Se steg 3. | +| Tom resultatliste | Databasen er tom. Kjør `npm run seed` i `backend/`. | +| Få treff, bare 199 varer | Du har seedet testdumpen. Hent den fulle dumpen og seed på nytt, se steg 4b. | +| `extension "pg_trgm" is not available` | Installer `postgresql-contrib` (Ubuntu) eller bruk en PostgreSQL-installasjon som inkluderer den. | + +## Kjøre på VM-en + + + +Backenden og databasen kjører på gruppas virtuelle maskin: + +- **GraphQL-backend:** `http://:3001/` +- **Database:** PostgreSQL på samme VM. Den er ikke tilgjengelig utenfra. + +Sjekk at backenden svarer: + +```bash +curl -s http://:3001/ \ + -H "Content-Type: application/json" \ + -d '{"query":"{ categories { name } }"}' +``` + +**Data på VM-en:** Den fulle dumpen må også ligge på VM-en, ellers har den bare 199 varer. Kopier den dit og seed der: + +```bash +# Fra din egen maskin: +scp ~/Downloads/kassalapp-dump-full.json @:/backend/data/kassalapp-dump.json + +# På VM-en, i backend/: +npm run seed +``` + +For å kjøre frontend lokalt mot backenden på VM-en, sett adressen i `frontend/.env` og start frontend på nytt: + +``` +VITE_GRAPHQL_URL=http://:3001/ +``` ## Arbeidsflyt diff --git a/backend/.env.example b/backend/.env.example index caddef9..2c9c364 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,3 +1,4 @@ PORT=3001 DATABASE_URL=postgres://localhost:5432/handlelapp +# Trengs bare for `npm run import`. Seed og vanlig kjøring fungerer uten. KASSALAPP_API_KEY= diff --git a/backend/src/index.ts b/backend/src/index.ts index d2f2e4c..def069b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,22 +1,327 @@ import 'dotenv/config'; import { ApolloServer } from '@apollo/server'; import { startStandaloneServer } from '@apollo/server/standalone'; +import { pool } from './db.js'; -// Midlertidig skjema for å sjekke at serveren kjører. -// Erstattes av det ekte skjemaet når databasen er på plass. const typeDefs = `#graphql + type Category { + id: ID! + parentId: ID + name: String! + } + + type Price { + storeCode: String! + price: Float! + unitPrice: Float + url: String + } + + type Product { + ean: String! + name: String! + brand: String + imageUrl: String + categoryId: ID + category: Category + prices: [Price!]! + } + type Query { - hello: String! + products( + search: String + category: ID + limit: Int = 20 + offset: Int = 0 + ): [Product!]! + + product(ean: String!): Product + + categories: [Category!]! + shoppingList(id: ID!): ShoppingList + + + } + + type ShoppingListItem { + id: ID! + ean: String! + quantity: Int! + note: String + checked: Boolean! + } + + type ShoppingList { + id: ID! + name: String! + status: String! + inUse: Boolean! + items: [ShoppingListItem!]! + } + + type Mutation { + createShoppingList(name: String!): ShoppingList! + + addItemToList( + listId: ID! + ean: String! + quantity: Int = 1 + ): ShoppingList! } `; const resolvers = { Query: { - hello: () => 'Hei fra Handlelappen-backend!', + categories: async () => { + const result = await pool.query(` + SELECT + id, + parent_id AS "parentId", + name + FROM categories + ORDER BY name + `); + + return result.rows; + }, + + product: async (_parent: unknown, args: { ean: string }) => { + const result = await pool.query( + ` + SELECT + ean, + name, + brand, + image_url AS "imageUrl", + category_id AS "categoryId" + FROM products + WHERE ean = $1 + `, + [args.ean], + ); + + return result.rows[0] ?? null; + }, + + + products: async ( + _parent: unknown, + args: { + search?: string; + category?: string; + limit?: number; + offset?: number; + }, + ) => { + const search = args.search ?? null; + const category = args.category ?? null; + const limit = args.limit ?? 20; + const offset = args.offset ?? 0; + + const result = await pool.query( + ` + SELECT + ean, + name, + brand, + image_url AS "imageUrl", + category_id AS "categoryId" + FROM products + WHERE + ($1::text IS NULL OR name ILIKE '%' || $1 || '%') + AND + ($2::integer IS NULL OR category_id = $2) + ORDER BY name + LIMIT $3 + OFFSET $4 + `, + [search, category, limit, offset], + ); + + return result.rows; + }, + + + shoppingList: async ( + _parent: unknown, + args: { id: string }, + ) => { + const result = await pool.query( + ` + SELECT + id, + name, + status, + in_use AS "inUse" + FROM shopping_lists + WHERE id = $1 + `, + [args.id], + ); + + return result.rows[0] ?? null; + }, + }, + + Mutation: { + createShoppingList: async ( + _parent: unknown, + args: { name: string }, + ) => { + const result = await pool.query( + ` + INSERT INTO shopping_lists (owner_id, name) + VALUES ($1, $2) + RETURNING + id, + name, + status, + in_use AS "inUse" + `, + ['anonymous', args.name], + ); + + return result.rows[0]; + }, + + addItemToList: async ( + _parent: unknown, + args: { + listId: string; + ean: string; + quantity?: number; + }, + ) => { + const quantity = args.quantity ?? 1; + + if (quantity <= 0) { + throw new Error('Antall må være større enn 0'); + } + + // Sjekk at handlelisten finnes + const listResult = await pool.query( + ` + SELECT id + FROM shopping_lists + WHERE id = $1 + `, + [args.listId], + ); + + if (listResult.rows.length === 0) { + throw new Error('Handlelisten finnes ikke'); + } + + // Sjekk at produktet finnes + const productResult = await pool.query( + ` + SELECT ean + FROM products + WHERE ean = $1 + `, + [args.ean], + ); + + if (productResult.rows.length === 0) { + throw new Error('Produktet finnes ikke'); + } + + // Legg til varen. + // Hvis varen allerede finnes i listen, økes quantity. + await pool.query( + ` + INSERT INTO list_items (list_id, ean, quantity) + VALUES ($1, $2, $3) + ON CONFLICT (list_id, ean) + DO UPDATE SET + quantity = list_items.quantity + EXCLUDED.quantity + `, + [args.listId, args.ean, quantity], + ); + + // Returner handlelisten + const result = await pool.query( + ` + SELECT + id, + name, + status, + in_use AS "inUse" + FROM shopping_lists + WHERE id = $1 + `, + [args.listId], + ); + + return result.rows[0]; + }, + }, + + ShoppingList: { + items: async (list: { id: string }) => { + const result = await pool.query( + ` + SELECT + id, + ean, + quantity, + note, + checked + FROM list_items + WHERE list_id = $1 + ORDER BY id + `, + [list.id], + ); + + return result.rows; + }, + }, + +//evt en ekstra { + + Product: { + category: async (product: { categoryId?: number | null }) => { + if (!product.categoryId) { + return null; + } + + const result = await pool.query( + ` + SELECT + id, + parent_id AS "parentId", + name + FROM categories + WHERE id = $1 + `, + [product.categoryId], + ); + + return result.rows[0] ?? null; + }, + + prices: async (product: { ean: string }) => { + const result = await pool.query( + ` + SELECT + store_code AS "storeCode", + price, + unit_price AS "unitPrice", + url + FROM prices + WHERE ean = $1 + ORDER BY price + `, + [product.ean], + ); + + return result.rows; + }, }, }; -const server = new ApolloServer({ typeDefs, resolvers }); +const server = new ApolloServer({ typeDefs, resolvers, introspection: true, }); // Port 3001 er kravet i oppgaven, men kan overstyres i .env const port = Number(process.env.PORT ?? 3001); diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..ae93746 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_GRAPHQL_URL=http://localhost:3001/ \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..438657a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.env # Editor directories and files .vscode/* diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 647ad91..fda09ff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,8 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@apollo/client": "^4.3.2", + "graphql": "^16.14.2", "react": "^19.2.8", "react-dom": "^19.2.8" }, @@ -30,6 +32,43 @@ "vite": "^8.3.0" } }, + "node_modules/@apollo/client": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.2.tgz", + "integrity": "sha512-ra6f8NhP4b5Wznw6VpRGOGF6Q6a8rORToQeIx5BMC5Rc7mRVZ9wCN+01ynIZPjmlGPNif2Lqt0LRD+futfNgIw==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@wry/caches": "^1.0.0", + "@wry/equality": "^0.5.6", + "@wry/trie": "^0.5.0", + "graphql-tag": "^2.12.6", + "optimism": "^0.18.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "graphql": "^16.0.0 || ^17.0.0", + "graphql-ws": "^5.5.5 || ^6.0.3", + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "react-dom": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "rxjs": "^7.3.0", + "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" + }, + "peerDependenciesMeta": { + "graphql-ws": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "subscriptions-transport-ws": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -422,6 +461,15 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1252,6 +1300,54 @@ } } }, + "node_modules/@wry/caches": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", + "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/context": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz", + "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/equality": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", + "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/trie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", + "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -1822,6 +1918,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.7.tgz", + "integrity": "sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/hashery": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", @@ -2338,6 +2458,18 @@ "node": ">=18" } }, + "node_modules/optimism": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz", + "integrity": "sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==", + "license": "MIT", + "dependencies": { + "@wry/caches": "^1.0.0", + "@wry/context": "^0.7.0", + "@wry/trie": "^0.5.0", + "tslib": "^2.3.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2552,6 +2684,16 @@ "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -2631,6 +2773,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9ec1dd6..6202da0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,8 @@ "preview": "vite preview" }, "dependencies": { + "@apollo/client": "^4.3.2", + "graphql": "^16.14.2", "react": "^19.2.8", "react-dom": "^19.2.8" }, diff --git a/frontend/src/App.css b/frontend/src/App.css index f90339d..1223237 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,184 +1,70 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; +/* Hjemskjerm – styling etter Figma (Handlelappen – notatbok). + Farger og skrift kommer fra styles/theme.css. */ - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } +#root { + max-width: 1100px; + margin: 0 auto; + padding: 48px 24px; } -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { +/* Søkefelt + knapp */ +.search { display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } + gap: 0.75rem; + justify-content: center; + align-items: center; + margin-bottom: 1.5rem; } -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } +.search input { + flex: 1; + max-width: 480px; + padding: 10px 14px; + font: 15px var(--font-ui); + color: var(--text-primary); + background: var(--bg-surface); + border: 1px solid var(--border-strong); + border-radius: 8px; } -#docs { - border-right: 1px solid var(--border); +.search input:focus { + outline: 2px solid var(--text-accent); + outline-offset: 2px; +} - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } +.search button { + background: var(--bg-primary); + color: var(--text-on-dark); + border: none; + border-radius: 8px; + padding: 10px 16px; + font: 500 15px var(--font-ui); + cursor: pointer; } -#next-steps ul { +/* Produktliste */ +.product-list { list-style: none; padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } + margin: 0; + text-align: left; } -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } +.product { + display: flex; + gap: 1rem; + align-items: center; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border-hairline); } -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} +.product img, +.no-image { + width: 64px; + height: 64px; + object-fit: contain; + background: var(--bg-surface); + border: 1px solid var(--border-brand); + border-radius: 6px; + flex-shrink: 0; +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9ca8e8d..ab7dfc1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,122 +1,123 @@ -import { useState } from 'react' -import heroImg from './assets/hero.png' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import './App.css' +import { gql } from "@apollo/client"; +import type { TypedDocumentNode } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; +import { useEffect, useState } from "react"; +import "./App.css"; + +type Price = { + storeCode: string; + price: number; +}; + +type Product = { + ean: string; + name: string; + brand: string | null; + imageUrl: string | null; + prices: Price[]; +}; + +type ProductsData = { + products: Product[]; +}; + +type ProductsVars = { + search?: string; + limit: number; +}; + +const PRODUCTS_QUERY: TypedDocumentNode = gql` + query Products($search: String, $limit: Int) { + products(search: $search, limit: $limit) { + ean + name + brand + imageUrl + prices { + storeCode + price + } + } + } +`; + +// Finner laveste pris blant butikkene, eller null hvis varen ikke har pris +function lowestPrice(prices: Price[]): number | null { + if (prices.length === 0) return null; + return Math.min(...prices.map((p) => p.price)); +} function App() { - const [count, setCount] = useState(0) + const [input, setInput] = useState(""); + const [search, setSearch] = useState(""); + + // Debounce: venter 400 ms etter siste tastetrykk før vi søker, + // slik at vi ikke sender en request for hver bokstav. + useEffect(() => { + const timer = setTimeout(() => setSearch(input.trim()), 400); + return () => clearTimeout(timer); + }, [input]); + + const { data, loading, error } = useQuery(PRODUCTS_QUERY, { + variables: { search: search || undefined, limit: 20 }, + }); return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) +
+

Handlelappen

+ +
+ + setInput(e.target.value)} + placeholder="F.eks. melk" + /> +
+ + {loading &&

Laster varer...

} + + {error &&

Kunne ikke hente varer: {error.message}

} + + {!loading && !error && data?.products.length === 0 && ( +

Ingen varer funnet

+ )} + + {data && data.products.length > 0 && ( +
    + {data.products.map((product) => { + const price = lowestPrice(product.prices); + return ( +
  • + {product.imageUrl ? ( + {product.name} + ) : ( +
  • + ); + })} +
+ )} +
+ ); } -export default App +export default App; \ No newline at end of file diff --git a/frontend/src/apolloClient.ts b/frontend/src/apolloClient.ts new file mode 100644 index 0000000..ec059a0 --- /dev/null +++ b/frontend/src/apolloClient.ts @@ -0,0 +1,14 @@ +import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; + +const graphqlUrl = import.meta.env.VITE_GRAPHQL_URL; + +if (!graphqlUrl) { + throw new Error("VITE_GRAPHQL_URL mangler"); +} + +export const apolloClient = new ApolloClient({ + link: new HttpLink({ + uri: graphqlUrl, + }), + cache: new InMemoryCache(), +}); \ No newline at end of file diff --git a/frontend/src/components/badge/Badge.css b/frontend/src/components/badge/Badge.css new file mode 100644 index 0000000..8e78c14 --- /dev/null +++ b/frontend/src/components/badge/Badge.css @@ -0,0 +1,27 @@ +/* Badge – fra Figma (Components → Badge) */ +.badge { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border-radius: 4px; + font: 500 10px var(--font-mono); + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; +} + +/* Style */ +.badge--primary { + background: var(--bg-primary); + color: var(--text-on-dark); +} + +.badge--dark { + background: var(--bg-brand); + color: var(--text-on-dark); +} + +.badge--tinted { + background: var(--bg-tint); + color: var(--text-brand); +} \ No newline at end of file diff --git a/frontend/src/components/button/Button.css b/frontend/src/components/button/Button.css new file mode 100644 index 0000000..7ff3d37 --- /dev/null +++ b/frontend/src/components/button/Button.css @@ -0,0 +1,66 @@ +/* Button – mål og farger fra Figma (Components → Button) */ +.button { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid transparent; + border-radius: 8px; + font: 500 14px var(--font-ui); + cursor: pointer; + transition: filter 0.15s; +} + +.button:hover { + filter: brightness(0.95); +} + +.button:focus-visible { + outline: 2px solid var(--text-accent); + outline-offset: 2px; +} + +/* Størrelse */ +.button--l { + height: 46px; + padding: 0 20px; +} + +.button--m { + height: 42px; + padding: 0 18px; +} + +.button--s { + height: 36px; + padding: 0 14px; + font-size: 13px; +} + +/* Type */ +.button--primary { + background: var(--bg-primary); + color: var(--text-on-dark); +} + +.button--dark { + background: var(--bg-brand); + color: var(--text-on-dark); +} + +.button--secondary { + background: var(--bg-surface); + color: var(--text-primary); + border-color: var(--border-hairline); +} + +.button--tinted { + background: var(--bg-tint); + color: var(--text-brand); +} + +/* Liten teller, f.eks. «Min lapp 08» */ +.button__count { + font-family: var(--font-mono); + font-size: 12px; + opacity: 0.8; +} \ No newline at end of file diff --git a/frontend/src/components/card/Card.css b/frontend/src/components/card/Card.css new file mode 100644 index 0000000..f826eba --- /dev/null +++ b/frontend/src/components/card/Card.css @@ -0,0 +1,15 @@ +/* Kort: hvit flate med burgunder kant og skarp skygge */ +.card { + background: var(--bg-surface); + color: var(--text-primary); + border: 1px solid var(--border-brand); + border-radius: 10px; + box-shadow: 3px 3px 0 var(--border-brand); + padding: 16px; + transition: box-shadow 0.15s, transform 0.15s; +} + +.card:hover { + box-shadow: 5px 5px 0 var(--border-brand); + transform: translate(-1px, -1px); +} \ No newline at end of file diff --git a/frontend/src/components/categoryCard/CategoryCard.css b/frontend/src/components/categoryCard/CategoryCard.css new file mode 100644 index 0000000..37bd1c9 --- /dev/null +++ b/frontend/src/components/categoryCard/CategoryCard.css @@ -0,0 +1,74 @@ +/* CategoryCard – fra Figma (Components → CategoryCard) */ +.category-card { + display: flex; + flex-direction: column; + justify-content: space-between; + min-width: 200px; + max-width: 400px; + height: 150px; + padding: 20px; + text-align: left; + font-family: var(--font-ui); + background: var(--bg-surface); + border: 1.25px solid var(--border-brand); + border-radius: 12px; + box-shadow: 3px 3px 0 var(--border-brand); + cursor: pointer; + transition: box-shadow 0.15s, transform 0.15s; +} + +.category-card:hover { + border-width: 1.75px; + box-shadow: 4px 4px 0 var(--border-brand); + transform: translate(-1px, -1px); +} + +.category-card__top { + display: flex; + justify-content: space-between; + font: 500 10px var(--font-mono); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); +} + +.category-card__top> :first-child { + color: var(--text-accent); +} + +.category-card__bottom { + display: flex; + justify-content: space-between; + align-items: flex-end; +} + +.category-card__name { + display: block; + font-size: 20px; + font-weight: 500; + letter-spacing: -0.02em; + color: var(--text-primary); +} + +.category-card__description { + display: block; + margin-top: 4px; + font-size: 13px; + color: var(--text-secondary); +} + +.category-card__arrow { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--bg-muted); + color: var(--text-primary); + transition: background 0.15s, color 0.15s; +} + +.category-card:hover .category-card__arrow { + background: var(--bg-primary); + color: var(--text-on-dark); +} \ No newline at end of file diff --git a/frontend/src/components/checkbox/Checkbox.css b/frontend/src/components/checkbox/Checkbox.css new file mode 100644 index 0000000..cb5b06d --- /dev/null +++ b/frontend/src/components/checkbox/Checkbox.css @@ -0,0 +1,40 @@ +/* Checkbox – fra Figma (Components → Checkbox) + Bruker en ekte , så den virker med tastatur og skjermleser. */ +.checkbox { + appearance: none; + display: inline-grid; + place-items: center; + flex-shrink: 0; + width: 16px; + height: 16px; + margin: 0; + background: var(--bg-surface); + border: 1.25px solid var(--border-brand); + border-radius: 3px; + cursor: pointer; +} + +.checkbox--large { + width: 18px; + height: 18px; +} + +/* Avkrysset: fylt burgunder med hvit hake */ +.checkbox:checked { + background: var(--bg-brand); + border-color: var(--bg-brand); +} + +.checkbox:checked::after { + content: ""; + width: 8px; + height: 4px; + border-left: 1.8px solid var(--text-on-dark); + border-bottom: 1.8px solid var(--text-on-dark); + transform: translateY(-1px) rotate(-45deg); +} + +.checkbox:focus-visible { + outline: 2px solid var(--text-accent); + outline-offset: 2px; +} diff --git a/frontend/src/components/filterChip/FilterChip.css b/frontend/src/components/filterChip/FilterChip.css new file mode 100644 index 0000000..8031354 --- /dev/null +++ b/frontend/src/components/filterChip/FilterChip.css @@ -0,0 +1,22 @@ +/* FilterChip – fra Figma (Components → FilterChip) + Viser et aktivt filter, med × for å fjerne det. */ +.filter-chip { + display: inline-flex; + align-items: center; + gap: 8px; + height: 28px; + padding: 0 10px; + border: none; + border-radius: 4px; + background: var(--bg-tint); + color: var(--text-brand); + font: 500 10px var(--font-mono); + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; +} + +.filter-chip__close { + font: 400 14px var(--font-ui); + line-height: 1; +} \ No newline at end of file diff --git a/frontend/src/components/filterRow/FilterRow.css b/frontend/src/components/filterRow/FilterRow.css new file mode 100644 index 0000000..385a703 --- /dev/null +++ b/frontend/src/components/filterRow/FilterRow.css @@ -0,0 +1,25 @@ +/* FilterRow – én rad i filterlisten (Components → FilterRow) */ +.filter-row { + display: flex; + align-items: center; + gap: 10px; + min-width: 160px; + cursor: pointer; +} + +.filter-row__name { + flex: 1; + font: 400 14px var(--font-ui); + color: var(--text-primary); +} + +.filter-row__count { + font: 500 10px var(--font-mono); + letter-spacing: 0.06em; + color: var(--text-muted); +} + +/* Valgt: navnet blir litt tykkere */ +.filter-row:has(.checkbox:checked) .filter-row__name { + font-weight: 500; +} \ No newline at end of file diff --git a/frontend/src/components/header/Header.css b/frontend/src/components/header/Header.css new file mode 100644 index 0000000..ebb1e61 --- /dev/null +++ b/frontend/src/components/header/Header.css @@ -0,0 +1,45 @@ +/* Header – fra Figma (Components → Header) + Inneholder Logo, enten SearchField eller navigasjon, og en Button (variant dark, size s). */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + height: 64px; + padding: 0 40px; + background: var(--bg-page); + border-bottom: 1px solid var(--border-hairline); +} + +.header .search-field { + flex: 1; +} + +.header__nav { + display: flex; + gap: 32px; +} + +.header__link { + font: 500 11px var(--font-mono); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-secondary); + text-decoration: none; +} + +.header__link:hover { + color: var(--text-accent); +} + +/* Mobil: mindre luft på sidene */ +@media (max-width: 600px) { + .header { + gap: 12px; + padding: 0 16px; + } + + .header__nav { + display: none; + } +} \ No newline at end of file diff --git a/frontend/src/components/logo/Logo.css b/frontend/src/components/logo/Logo.css new file mode 100644 index 0000000..c508ff2 --- /dev/null +++ b/frontend/src/components/logo/Logo.css @@ -0,0 +1,35 @@ +/* Logo – fra Figma (Components → Logo) */ +.logo { + display: inline-flex; + align-items: center; + gap: 12px; + font: 600 18px var(--font-ui); + letter-spacing: -0.02em; + color: var(--text-primary); + text-decoration: none; +} + +/* To skjeve lapper oppå hverandre */ +.logo__mark { + position: relative; + width: 26px; + height: 26px; +} + +.logo__mark::before, +.logo__mark::after { + content: ""; + position: absolute; + inset: 3px; + border-radius: 2px; +} + +.logo__mark::before { + background: var(--bg-brand); + transform: rotate(-6deg); +} + +.logo__mark::after { + background: var(--bg-primary); + transform: rotate(8deg) translate(-2px, -2px); +} \ No newline at end of file diff --git a/frontend/src/components/noteItem/NoteItem.css b/frontend/src/components/noteItem/NoteItem.css new file mode 100644 index 0000000..bf21d20 --- /dev/null +++ b/frontend/src/components/noteItem/NoteItem.css @@ -0,0 +1,26 @@ +/* NoteItem – en vare på handlelappen (Components → NoteItem) + Ligger inni .note fra theme.css. */ +.note-item { + display: flex; + align-items: center; + gap: 12px; + padding: 7px 0; + border-bottom: 1px solid var(--note-rule); + font-family: var(--font-note); +} + +.note-item__name { + flex: 1; + font-size: 19px; + color: var(--note-ink); +} + +.note-item__count { + font-size: 17px; + color: var(--text-secondary); +} + +/* Avkrysset: varen blir grå */ +.note-item--checked .note-item__name { + color: var(--text-muted); +} \ No newline at end of file diff --git a/frontend/src/components/priceRow/PriceRow.css b/frontend/src/components/priceRow/PriceRow.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/quantityStepper/QuantityStepper.css b/frontend/src/components/quantityStepper/QuantityStepper.css new file mode 100644 index 0000000..a75af3d --- /dev/null +++ b/frontend/src/components/quantityStepper/QuantityStepper.css @@ -0,0 +1,31 @@ +/* QuantityStepper – fra Figma (Components → QuantityStepper) */ +.quantity-stepper { + display: inline-flex; + align-items: center; + gap: 14px; + height: 32px; + padding: 0 12px; + background: var(--bg-surface); + border: 1px solid var(--border-hairline); + border-radius: 6px; +} + +.quantity-stepper__button { + padding: 0; + border: none; + background: none; + color: var(--text-muted); + font: 400 14px var(--font-ui); + cursor: pointer; +} + +.quantity-stepper__button:hover { + color: var(--text-primary); +} + +.quantity-stepper__value { + min-width: 1ch; + text-align: center; + font: 500 11px var(--font-mono); + color: var(--text-primary); +} \ No newline at end of file diff --git a/frontend/src/components/resultRow/ResultRow.css b/frontend/src/components/resultRow/ResultRow.css new file mode 100644 index 0000000..a9bcd4e --- /dev/null +++ b/frontend/src/components/resultRow/ResultRow.css @@ -0,0 +1,112 @@ +/* ResultRow – én vare i søkeresultatet (Components → ResultRow) + Kolonner: nummer · navn og detaljer · kr per kg · pris · knapp */ +.result-row { + display: flex; + align-items: center; + gap: 16px; + min-width: 480px; + padding: 14px 0; + border-bottom: 1px solid var(--border-hairline); +} + +.result-row__number, +.result-row__price-per-kg, +.result-row__chain { + font: 500 10px var(--font-mono); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); +} + +.result-row__number { + width: 36px; + flex-shrink: 0; +} + +.result-row__info { + display: flex; + flex-direction: column; + gap: 3px; + flex: 1; + min-width: 0; +} + +.result-row__name { + font: 500 16px var(--font-ui); + letter-spacing: -0.01em; + color: var(--text-primary); +} + +.result-row__details { + font: 500 9px var(--font-mono); + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--text-muted); +} + +.result-row__price-per-kg { + width: 96px; + flex-shrink: 0; +} + +.result-row__price-column { + display: flex; + flex-direction: column; + gap: 2px; + width: 110px; + flex-shrink: 0; +} + +.result-row__price { + font: 500 18px var(--font-ui); + letter-spacing: -0.02em; + color: var(--text-primary); +} + +.result-row__chain { + color: var(--text-accent); +} + +/* Knappen til høyre er en Button (variant secondary, size s) med fast bredde */ +.result-row .button { + width: 150px; + justify-content: center; +} + +/* Når varen står på lappen: rosa felt med − antall + */ +.result-row__on-list { + display: flex; + align-items: center; + justify-content: space-between; + width: 150px; + height: 36px; + padding: 0 12px; + box-sizing: border-box; + background: var(--bg-tint); + border-radius: 8px; + color: var(--text-brand); + font: 500 10px var(--font-mono); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.result-row__on-list button { + padding: 0; + border: none; + background: none; + color: inherit; + font: 500 16px var(--font-ui); + cursor: pointer; +} + +/* Mobil: skjul kr per kg og nummer for å få plass */ +@media (max-width: 600px) { + .result-row { + min-width: 0; + } + + .result-row__number, + .result-row__price-per-kg { + display: none; + } +} \ No newline at end of file diff --git a/frontend/src/components/searchfield/Searchfield.css b/frontend/src/components/searchfield/Searchfield.css new file mode 100644 index 0000000..730825e --- /dev/null +++ b/frontend/src/components/searchfield/Searchfield.css @@ -0,0 +1,86 @@ +/* SearchField – fra Figma (Components → SearchField) + Variantene: standard (i headeren) og --large (på forsiden). */ +.search-field { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-width: 200px; + max-width: 640px; + height: 40px; + padding: 0 12px 0 16px; + box-sizing: border-box; + background: var(--bg-muted); + border-radius: 8px; +} + +/* Søkeikonet: en sirkel med et skaft */ +.search-field__icon { + position: relative; + flex-shrink: 0; + width: 16px; + height: 16px; +} + +.search-field__icon::before { + content: ""; + position: absolute; + top: 1px; + left: 1px; + width: 9px; + height: 9px; + border: 1.5px solid var(--text-muted); + border-radius: 50%; +} + +.search-field__icon::after { + content: ""; + position: absolute; + right: 1px; + bottom: 2px; + width: 5px; + height: 1.5px; + background: var(--text-muted); + transform: rotate(45deg); +} + +.search-field__input { + flex: 1; + min-width: 0; + border: none; + background: transparent; + font: 400 14px var(--font-ui); + color: var(--text-primary); + outline: none; +} + +.search-field__input::placeholder { + color: var(--text-muted); +} + +.search-field:focus-within { + outline: 2px solid var(--text-accent); + outline-offset: 2px; +} + +/* Liten «⌘K»-snarvei til høyre */ +.search-field__shortcut { + padding: 2px 6px; + border: 1px solid var(--border-hairline); + border-radius: 4px; + font: 500 10px var(--font-mono); + color: var(--text-muted); +} + +/* Stor variant: hvit, tydelig kant og en knapp inni */ +.search-field--large { + height: 60px; + padding: 0 8px 0 16px; + background: var(--bg-surface); + border: 1.25px solid var(--border-strong); + border-radius: 10px; +} + +.search-field--large .search-field__input { + font-size: 17px; +} \ No newline at end of file diff --git a/frontend/src/components/segmentedControl/SegmentedControl.css b/frontend/src/components/segmentedControl/SegmentedControl.css new file mode 100644 index 0000000..01e06da --- /dev/null +++ b/frontend/src/components/segmentedControl/SegmentedControl.css @@ -0,0 +1,28 @@ +/* SegmentedControl – fra Figma (Components → SegmentedControl) + F.eks. «Aktive / Tidligere». Den valgte får klassen --active. */ +.segmented-control { + display: inline-flex; + padding: 3px; + background: var(--bg-muted); + border: 1px solid var(--border-hairline); + border-radius: 8px; +} + +.segmented-control__option { + height: 32px; + padding: 0 14px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + font: 500 10px var(--font-mono); + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; +} + +.segmented-control__option--active { + background: var(--bg-surface); + border-color: var(--border-brand); + color: var(--text-brand); +} \ No newline at end of file diff --git a/frontend/src/components/shoppingListRow/ShoppingListRow.css b/frontend/src/components/shoppingListRow/ShoppingListRow.css new file mode 100644 index 0000000..4a62ab0 --- /dev/null +++ b/frontend/src/components/shoppingListRow/ShoppingListRow.css @@ -0,0 +1,68 @@ +/* ShoppingListRow – én vare på aktiv handleliste (Components → ShoppingListRow) + Inneholder Checkbox (--large) og QuantityStepper. */ +.shopping-list-row { + display: flex; + align-items: center; + gap: 16px; + min-width: 360px; + padding: 8px 0; + border-bottom: 1px solid var(--note-rule); +} + +.shopping-list-row__name { + display: flex; + align-items: baseline; + gap: 10px; + flex: 1; + min-width: 0; + font-family: var(--font-note); +} + +.shopping-list-row__item { + font-size: 25px; + color: var(--note-ink); +} + +.shopping-list-row__note { + font-size: 16px; + color: var(--text-secondary); +} + +.shopping-list-row__price { + width: 84px; + text-align: right; + font: 500 11px var(--font-mono); + letter-spacing: 0.06em; + color: var(--text-primary); +} + +.shopping-list-row__remove { + padding: 0; + border: none; + background: none; + color: var(--text-muted); + font: 500 16px var(--font-ui); + cursor: pointer; +} + +.shopping-list-row__remove:hover { + color: var(--text-accent); +} + +/* Avkrysset: overstreket og grå */ +.shopping-list-row--checked .shopping-list-row__item { + color: var(--text-muted); + text-decoration: line-through; +} + +.shopping-list-row--checked .shopping-list-row__price { + color: var(--text-muted); +} + +/* Mobil */ +@media (max-width: 600px) { + .shopping-list-row { + min-width: 0; + gap: 10px; + } +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..68cb6c4 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,16 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { ApolloProvider } from "@apollo/client/react"; import './index.css' import App from './App.tsx' +import { apolloClient } from "./apolloClient"; +import './theme.css' + createRoot(document.getElementById('root')!).render( - + + + , -) +); diff --git a/frontend/src/theme.css b/frontend/src/theme.css new file mode 100644 index 0000000..a1a8287 --- /dev/null +++ b/frontend/src/theme.css @@ -0,0 +1,176 @@ +/* Handlelappen – farger og skrift fra Figma (Handlelappen – notatbok). + Variabelnavnene er de samme som i Figma (Local variables → Color), + bare med «-» i stedet for «/»: bg/primary i Figma = --bg-primary her. + Dev Mode i Figma viser de samme navnene, så CSS-en kan kopieres rett inn. + + Lys og mørk modus: + - Følger innstillingen på maskinen som standard. + - Kan overstyres med (se ThemeToggle.tsx). */ +@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&family=Instrument+Serif:ital@0;1&family=Kalam:wght@400;700&display=swap'); + +/* ---------- Lys modus (standard) ---------- */ +:root { + color-scheme: light; + + --bg-page: #ffffff; + --bg-muted: #f7f7f7; + --bg-surface: #ffffff; + --bg-primary: #d3045d; + --bg-brand: #65081f; + --bg-tint: #fce8f0; + + --text-primary: #141414; + --text-secondary: #5e5e5e; + --text-muted: #8a8a8a; + --text-brand: #65081f; + --text-accent: #d3045d; + --text-on-dark: #ffffff; + + --border-hairline: #e3e3e3; + --border-grid: #efefef; + --border-strong: #141414; + --border-brand: #65081f; + + --note-paper: #ffffff; + --note-tint: #fce8f0; + --note-rule: #f7cfdf; + --note-margin: #d3045d; + --note-tape: #f7cfdf; + --note-ink: #65081f; + + --annotation: #d3045d; + + /* Skrift */ + --font-ui: 'Geist', system-ui, sans-serif; + --font-mono: 'Geist Mono', ui-monospace, monospace; + --font-accent: 'Instrument Serif', Georgia, serif; + --font-note: 'Kalam', cursive; +} + +/* ---------- Mørk modus ---------- */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + + --bg-page: #241319; + --bg-muted: #1e1015; + --bg-surface: #301a22; + --bg-primary: #ff4f8b; + --bg-brand: #8c1838; + --bg-tint: #4a2230; + + --text-primary: #f6ece6; + --text-secondary: #d6c3c3; + --text-muted: #a68e90; + --text-brand: #f4a3b8; + --text-accent: #ff6b9e; + --text-on-dark: #ffffff; + + --border-hairline: #43293a; + --border-grid: #2d1a21; + --border-strong: #eadbd5; + --border-brand: #f4a3b8; + + --note-paper: #301a22; + --note-tint: #4a2230; + --note-rule: #5a2c3d; + --note-margin: #ff4f8b; + --note-tape: #6b3346; + --note-ink: #f4a3b8; + + --annotation: #ff6b9e; + } +} + +:root[data-theme="dark"] { + color-scheme: dark; + + --bg-page: #241319; + --bg-muted: #1e1015; + --bg-surface: #301a22; + --bg-primary: #ff4f8b; + --bg-brand: #8c1838; + --bg-tint: #4a2230; + + --text-primary: #f6ece6; + --text-secondary: #d6c3c3; + --text-muted: #a68e90; + --text-brand: #f4a3b8; + --text-accent: #ff6b9e; + --text-on-dark: #ffffff; + + --border-hairline: #43293a; + --border-grid: #2d1a21; + --border-strong: #eadbd5; + --border-brand: #f4a3b8; + + --note-paper: #301a22; + --note-tint: #4a2230; + --note-rule: #5a2c3d; + --note-margin: #ff4f8b; + --note-tape: #6b3346; + --note-ink: #f4a3b8; + + --annotation: #ff6b9e; +} + +/* ---------- Grunnstil ---------- */ +body { + margin: 0; + background: var(--bg-page); + color: var(--text-primary); + font-family: var(--font-ui); + transition: background-color 0.2s, color 0.2s; +} + +/* Små etiketter i monospace, f.eks. «01 · KATEGORIER» */ +.label { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); +} + +/* Kursiv aksent i overskrifter, f.eks.

Handlelappen

*/ +h1 em, +h2 em { + font-family: var(--font-accent); + font-style: italic; + font-weight: 400; + color: var(--text-brand); +} + +/* Rutemønster bak kategoriene */ +.grid-pattern { + background-color: var(--bg-muted); + background-image: + linear-gradient(var(--border-grid) 1px, transparent 1px), + linear-gradient(90deg, var(--border-grid) 1px, transparent 1px); + background-size: 24px 24px; +} + +/* Handlelappen: linjert papir, bringebær marg og tynn kontur */ +.note { + font-family: var(--font-note); + color: var(--note-ink); + background-color: var(--note-paper); + background-image: repeating-linear-gradient(transparent 0 31px, var(--note-rule) 31px 32px); + border: 1px solid var(--border-strong); + border-left: 3px solid var(--note-margin); + padding: 16px 16px 16px 28px; + line-height: 32px; + box-shadow: 6px 6px 0 var(--note-tint); + transform: rotate(-1deg); +} + +/* Knappen som bytter mellom lys og mørk modus */ +.theme-toggle { + background: var(--bg-surface); + color: var(--text-primary); + border: 1px solid var(--border-strong); + border-radius: 999px; + padding: 6px 12px; + font: 500 13px var(--font-mono); + cursor: pointer; +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d789a7c..974401e 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,9 +3,10 @@ import babel from '@rolldown/plugin-babel' import { defineConfig } from 'vite' // https://vite.dev/config/ -export default defineConfig({ +export default defineConfig(({ command }) => ({ + base: command === 'build' ? '/project2/' : '/', plugins: [ react(), babel({ presets: [reactCompilerPreset()] }) ], -}) +})) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..749f7fc --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "T27-Project-2", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}