From 84214c42bea9dda7f2ee947915c56ca1efeac1b3 Mon Sep 17 00:00:00 2001 From: Tara Aqeel Date: Tue, 6 Oct 2026 18:37:40 +0200 Subject: [PATCH 01/12] Jeg har lagt til GraphQL-typer og mutasjoner for handleliste --- backend/src/index.ts | 176 +++++++++++++++++++++++++++++++++++++++++-- package-lock.json | 6 ++ 2 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 package-lock.json diff --git a/backend/src/index.ts b/backend/src/index.ts index d2f2e4c..95bd2d6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,22 +1,188 @@ 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; + }, + }, + + 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/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": {} +} From 1f0fe3755efc2d941daca3ee3f3b18ea6f71d268 Mon Sep 17 00:00:00 2001 From: Tara Aqeel Date: Tue, 6 Oct 2026 18:45:30 +0200 Subject: [PATCH 02/12] =?UTF-8?q?Mutatiion=20for=20handlelist=20s=C3=A5=20?= =?UTF-8?q?handleliste=20faktisk=20blir=20laget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/index.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/backend/src/index.ts b/backend/src/index.ts index 95bd2d6..3b4665e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -102,6 +102,28 @@ const resolvers = { 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]; + }, + }, + products: async ( _parent: unknown, args: { @@ -182,6 +204,8 @@ const resolvers = { }; + + const server = new ApolloServer({ typeDefs, resolvers, introspection: true, }); // Port 3001 er kravet i oppgaven, men kan overstyres i .env From c3a31d973180b1cbb737f6112b31c031ecc1ee24 Mon Sep 17 00:00:00 2001 From: Tara Aqeel Date: Tue, 6 Oct 2026 18:53:34 +0200 Subject: [PATCH 03/12] Rettet opp i feil ved plassering av mutation-kode --- backend/src/index.ts | 43 ++++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 3b4665e..77dbe84 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -102,27 +102,6 @@ const resolvers = { 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]; - }, - }, products: async ( _parent: unknown, @@ -162,6 +141,28 @@ const resolvers = { }, }, + 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]; + }, + }, + Product: { category: async (product: { categoryId?: number | null }) => { if (!product.categoryId) { From 7a4f2561783437031e221721cf38f35f8817895b Mon Sep 17 00:00:00 2001 From: Annisa Alexis Muljono Date: Wed, 7 Oct 2026 06:15:42 +0200 Subject: [PATCH 04/12] Sett opp Apollo Client for issue #14 --- frontend/.env.example | 1 + frontend/.gitignore | 1 + frontend/package-lock.json | 148 +++++++++++++++++++++++++++++++++++ frontend/package.json | 2 + frontend/src/App.tsx | 143 +++++++-------------------------- frontend/src/apolloClient.ts | 14 ++++ frontend/src/main.tsx | 8 +- 7 files changed, 200 insertions(+), 117 deletions(-) create mode 100644 frontend/.env.example create mode 100644 frontend/src/apolloClient.ts 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.tsx b/frontend/src/App.tsx index 9ca8e8d..6651d90 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,122 +1,35 @@ -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 "./App.css"; -function App() { - const [count, setCount] = useState(0) +type HelloQueryData = { + hello: string; +}; - return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

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

-
- -
+const HELLO_QUERY: TypedDocumentNode = gql` + query Hello { + hello + } +`; -
+function App() { + const { data, loading, error } = useQuery(HELLO_QUERY); -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
+ if (loading) { + return

Kontakter backend...

; + } -
-
- - ) + if (error) { + return

Kunne ikke kontakte backend: {error.message}

; + } + + return ( +
+

Handlelappen

+

{data?.hello}

+
+ ); } -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/main.tsx b/frontend/src/main.tsx index bef5202..35d827f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,14 @@ 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"; createRoot(document.getElementById('root')!).render( - + + + , -) +); From 99927e18529c5cc922be6e20c48b77d27314df39 Mon Sep 17 00:00:00 2001 From: Tara Aqeel Date: Thu, 8 Oct 2026 13:06:22 +0200 Subject: [PATCH 05/12] finpussing av kode --- backend/src/index.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/backend/src/index.ts b/backend/src/index.ts index 2917ae4..e6972be 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -139,6 +139,26 @@ const resolvers = { 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: { From 5a74123da92f05bcabde845e6696ce1d5356e73b Mon Sep 17 00:00:00 2001 From: Annisa Alexis Muljono Date: Thu, 8 Oct 2026 22:04:35 +0200 Subject: [PATCH 06/12] =?UTF-8?q?feat(#14):=20vis=20bilde=20og=20pris,=20d?= =?UTF-8?q?ebounce=20i=20s=C3=B8k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.css | 34 +++++++++++++ frontend/src/App.tsx | 114 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 135 insertions(+), 13 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index f90339d..32b3c38 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -182,3 +182,37 @@ border-right-color: var(--border); } } + +/* === MIDLERTIDIG STYLING FOR HJEMKJERM === */ +.search { + display: flex; + gap: 0.75rem; + justify-content: center; + align-items: center; + margin-bottom: 1.5rem; +} + +.product-list { + list-style: none; + padding: 0; + margin: 0; + text-align: left; +} + +.product { + display: flex; + gap: 1rem; + align-items: center; + padding: 0.75rem 0; + border-bottom: 1px solid #444; +} + +.product img, +.no-image { + width: 64px; + height: 64px; + object-fit: contain; + background: #fff; + 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 6651d90..9461e05 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,33 +1,121 @@ 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 HelloQueryData = { - hello: string; +type Price = { + storeCode: string; + price: number; }; -const HELLO_QUERY: TypedDocumentNode = gql` - query Hello { - hello +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 { data, loading, error } = useQuery(HELLO_QUERY); + const [input, setInput] = useState(""); + const [search, setSearch] = useState(""); - if (loading) { - return

Kontakter backend...

; - } + // 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]); - if (error) { - return

Kunne ikke kontakte backend: {error.message}

; - } + const { data, loading, error } = useQuery(PRODUCTS_QUERY, { + variables: { search: search || undefined, limit: 20 }, + }); return (

Handlelappen

-

{data?.hello}

+ +
+ + 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} + ) : ( +
  • + ); + })} +
+ )}
); } From f849ad308b9c06482c95063aa8dd99cd67b1efe2 Mon Sep 17 00:00:00 2001 From: Tara Aqeel Date: Wed, 7 Oct 2026 10:23:58 +0200 Subject: [PATCH 07/12] feat(#13): legg til GraphQL-skjema og resolvere for varer og kategorier --- backend/src/index.ts | 175 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 170 insertions(+), 5 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index d2f2e4c..bfe2554 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,22 +1,187 @@ 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!]! + + } + + 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; + }, + }, + + 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); From b20369b49ba4d5e2b3bda969b625920a95601ed0 Mon Sep 17 00:00:00 2001 From: Linde Vrenne Date: Fri, 9 Oct 2026 12:38:22 +0200 Subject: [PATCH 08/12] feat: lagt til figma styling --- frontend/src/App.css | 220 +++++++----------------------------------- frontend/src/App.tsx | 6 +- frontend/src/main.tsx | 1 + frontend/src/tema.css | 90 +++++++++++++++++ 4 files changed, 130 insertions(+), 187 deletions(-) create mode 100644 frontend/src/tema.css diff --git a/frontend/src/App.css b/frontend/src/App.css index 32b3c38..f987250 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,197 +1,48 @@ -.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 tema.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 { - 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; - } -} - -#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; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; +/* Søkefelt + knapp */ +.search { 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; - } - } + gap: 0.75rem; + justify-content: center; + align-items: center; + margin-bottom: 1.5rem; } -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } +.search input { + flex: 1; + max-width: 480px; + padding: 10px 14px; + font: 15px var(--font-ui); + color: var(--blekk); + background: var(--hvit); + border: 1px solid var(--blekk); + border-radius: 8px; } -.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); - } +.search input:focus { + outline: 2px solid var(--bringebaer); + outline-offset: 2px; } -/* === MIDLERTIDIG STYLING FOR HJEMKJERM === */ -.search { - display: flex; - gap: 0.75rem; - justify-content: center; - align-items: center; - margin-bottom: 1.5rem; +.search button { + background: var(--bringebaer); + color: var(--hvit); + border: none; + border-radius: 8px; + padding: 10px 16px; + font: 500 15px var(--font-ui); + cursor: pointer; } +/* Produktliste */ .product-list { list-style: none; padding: 0; @@ -204,7 +55,7 @@ gap: 1rem; align-items: center; padding: 0.75rem 0; - border-bottom: 1px solid #444; + border-bottom: 1px solid #e5e5e5; } .product img, @@ -212,7 +63,8 @@ width: 64px; height: 64px; object-fit: contain; - background: #fff; + background: var(--hvit); + border: 1px solid var(--burgunder); 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 9461e05..fd4dd23 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -64,10 +64,10 @@ function App() { return (
-

Handlelappen

+

Handlelappen

- + 0 && ( -
    +
      {data.products.map((product) => { const price = lowestPrice(product.prices); return ( diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 35d827f..f044fb2 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,7 @@ import { ApolloProvider } from "@apollo/client/react"; import './index.css' import App from './App.tsx' import { apolloClient } from "./apolloClient"; +import './tema.css' createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/tema.css b/frontend/src/tema.css new file mode 100644 index 0000000..5258fae --- /dev/null +++ b/frontend/src/tema.css @@ -0,0 +1,90 @@ +/* Handlelappen – notatbok: farger og skrift fra Figma (siden «Grunnlag») */ +@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'); + +:root { + /* Farger */ + --hvit: #ffffff; /* bakgrunn og lapper */ + --blekk: #141414; /* tekst og linjer */ + --burgunder: #65081f; /* håndskrift, merke, kant på kort */ + --bringebaer: #d3045d; /* knapper, kommentarer */ + --rosa: #fce8f0; /* lapper, markering */ + + /* Skrift */ + --font-ui: 'Geist', system-ui, sans-serif; + --font-mono: 'Geist Mono', ui-monospace, monospace; + --font-aksent: 'Instrument Serif', Georgia, serif; + --font-lapp: 'Kalam', cursive; +} + +body { + margin: 0; + background: var(--hvit); + color: var(--blekk); + font-family: var(--font-ui); +} + +/* Etiketter som «01 · KATEGORIER» */ +.etikett { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +/* Kursiv aksent i overskrifter, f.eks.

      Handlelappen

      */ +h1 em, h2 em { + font-family: var(--font-aksent); + font-style: italic; + font-weight: 400; + color: var(--burgunder); +} + +/* Knapper */ +.knapp { + background: var(--bringebaer); + color: var(--hvit); + border: none; + border-radius: 8px; + padding: 10px 16px; + font: 500 15px var(--font-ui); + cursor: pointer; +} + +/* Rutemønster bak kategoriene */ +.rutemonster { + background-color: #fafafa; + background-image: + linear-gradient(#eeeeee 1px, transparent 1px), + linear-gradient(90deg, #eeeeee 1px, transparent 1px); + background-size: 24px 24px; +} + +/* Kategorikort: burgunder kant og skarp skygge */ +.kort { + background: var(--hvit); + border: 1px solid var(--burgunder); + border-radius: 10px; + box-shadow: 3px 3px 0 var(--burgunder); + padding: 16px; + transition: box-shadow 0.15s, transform 0.15s; +} +.kort:hover { + border-width: 2px; + box-shadow: 5px 5px 0 var(--burgunder); + transform: translate(-1px, -1px); +} + +/* Handlelappen: hvit lapp med rosa linjer, rød marg og tynn svart kontur */ +.lapp { + font-family: var(--font-lapp); + color: var(--burgunder); + background-color: var(--hvit); + background-image: repeating-linear-gradient( + transparent 0 31px, var(--rosa) 31px 32px); + border: 1px solid var(--blekk); + border-left: 3px solid var(--bringebaer); + padding: 16px 16px 16px 28px; + line-height: 32px; + box-shadow: 6px 6px 0 var(--rosa); + transform: rotate(-1deg); +} From 8676bd399534774ee52bc7e629b9d5f56fd16599 Mon Sep 17 00:00:00 2001 From: Ingrihka Date: Fri, 9 Oct 2026 12:54:08 +0200 Subject: [PATCH 09/12] =?UTF-8?q?docs:=20installasjon,=20kj=C3=B8ring,=20A?= =?UTF-8?q?PI=20og=20status=20i=20README=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 237 ++++++++++++++++++++++++++++++++++++++++++- backend/.env.example | 1 + 2 files changed, 234 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2cf44ca..617c6a1 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é @@ -99,7 +99,74 @@ 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 { @@ -157,19 +224,181 @@ 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**. +- **Kassalapp API-nøkkel** trengs bare hvis du skal hente nye data. En ferdig dump ligger i `backend/data/kassalapp-dump.json`, så du kan sette opp databasen uten nøkkel. + +### 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 + +Start PostgreSQL, lag databasen og kjør skjemaet. Kommandoene kjøres fra `backend/`: + +```bash +cd backend +createdb handlelapp +psql handlelapp -f db/migrations/001_init.sql +npm run seed +``` + +- `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. +- `npm run seed` fyller databasen fra `data/kassalapp-dump.json`. Den kan også kjøres flere ganger, siden eksisterende varer og priser oppdateres i stedet for å dupliseres. Den må kjøres fra `backend/`, fordi filstien er relativ. + +Når seeden er ferdig, skriver den ut hvor mange kategorier, varer og priser som ble lagt inn. + +### 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) + +Dette trengs bare hvis 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/`. | +| `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 } }"}' +``` + +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= From efdfb1655bf5d35d0670d80cc86837031e14cf49 Mon Sep 17 00:00:00 2001 From: Linde Vrenne Date: Fri, 9 Oct 2026 15:42:43 +0200 Subject: [PATCH 10/12] =?UTF-8?q?Sett=20base=20til=20/project2/=20for=20by?= =?UTF-8?q?gg=20p=C3=A5=20VM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/vite.config.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 From 70ded8e3c38c2cfc21744b7fe085c10f72111ab8 Mon Sep 17 00:00:00 2001 From: Linde Vrenne Date: Mon, 12 Oct 2026 13:47:38 +0200 Subject: [PATCH 11/12] =?UTF-8?q?La=20til=20figma=20styling=20med=20kompon?= =?UTF-8?q?enter=20og=20theme.=20Endret=20litt=20p=C3=A5=20App.tsx=20slik?= =?UTF-8?q?=20at=20test=20siden=20ogs=C3=A5=20har=20mer=20v=C3=A5rt=20tema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.css | 20 +- frontend/src/App.tsx | 2 +- frontend/src/components/badge/Badge.css | 27 +++ frontend/src/components/button/Button.css | 66 +++++++ frontend/src/components/card/Card.css | 15 ++ .../components/categoryCard/CategoryCard.css | 74 ++++++++ frontend/src/components/checkbox/Checkbox.css | 40 ++++ .../src/components/filterChip/FilterChip.css | 22 +++ .../src/components/filterRow/FilterRow.css | 25 +++ frontend/src/components/header/Header.css | 45 +++++ frontend/src/components/logo/Logo.css | 35 ++++ frontend/src/components/noteItem/NoteItem.css | 26 +++ frontend/src/components/priceRow/PriceRow.css | 0 .../quantityStepper/QuantityStepper.css | 31 +++ .../src/components/resultRow/ResultRow.css | 112 +++++++++++ .../components/searchfield/Searchfield.css | 86 +++++++++ .../segmentedControl/SegmentedControl.css | 28 +++ .../shoppingListRow/ShoppingListRow.css | 68 +++++++ frontend/src/main.tsx | 3 +- frontend/src/tema.css | 90 --------- frontend/src/theme.css | 176 ++++++++++++++++++ 21 files changed, 889 insertions(+), 102 deletions(-) create mode 100644 frontend/src/components/badge/Badge.css create mode 100644 frontend/src/components/button/Button.css create mode 100644 frontend/src/components/card/Card.css create mode 100644 frontend/src/components/categoryCard/CategoryCard.css create mode 100644 frontend/src/components/checkbox/Checkbox.css create mode 100644 frontend/src/components/filterChip/FilterChip.css create mode 100644 frontend/src/components/filterRow/FilterRow.css create mode 100644 frontend/src/components/header/Header.css create mode 100644 frontend/src/components/logo/Logo.css create mode 100644 frontend/src/components/noteItem/NoteItem.css create mode 100644 frontend/src/components/priceRow/PriceRow.css create mode 100644 frontend/src/components/quantityStepper/QuantityStepper.css create mode 100644 frontend/src/components/resultRow/ResultRow.css create mode 100644 frontend/src/components/searchfield/Searchfield.css create mode 100644 frontend/src/components/segmentedControl/SegmentedControl.css create mode 100644 frontend/src/components/shoppingListRow/ShoppingListRow.css delete mode 100644 frontend/src/tema.css create mode 100644 frontend/src/theme.css diff --git a/frontend/src/App.css b/frontend/src/App.css index f987250..1223237 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,5 +1,5 @@ /* Hjemskjerm – styling etter Figma (Handlelappen – notatbok). - Farger og skrift kommer fra tema.css. */ + Farger og skrift kommer fra styles/theme.css. */ #root { max-width: 1100px; @@ -21,20 +21,20 @@ max-width: 480px; padding: 10px 14px; font: 15px var(--font-ui); - color: var(--blekk); - background: var(--hvit); - border: 1px solid var(--blekk); + color: var(--text-primary); + background: var(--bg-surface); + border: 1px solid var(--border-strong); border-radius: 8px; } .search input:focus { - outline: 2px solid var(--bringebaer); + outline: 2px solid var(--text-accent); outline-offset: 2px; } .search button { - background: var(--bringebaer); - color: var(--hvit); + background: var(--bg-primary); + color: var(--text-on-dark); border: none; border-radius: 8px; padding: 10px 16px; @@ -55,7 +55,7 @@ gap: 1rem; align-items: center; padding: 0.75rem 0; - border-bottom: 1px solid #e5e5e5; + border-bottom: 1px solid var(--border-hairline); } .product img, @@ -63,8 +63,8 @@ width: 64px; height: 64px; object-fit: contain; - background: var(--hvit); - border: 1px solid var(--burgunder); + 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 fd4dd23..ab7dfc1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -86,7 +86,7 @@ function App() { )} {data && data.products.length > 0 && ( -
        +
          {data.products.map((product) => { const price = lowestPrice(product.prices); return ( 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 f044fb2..68cb6c4 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,7 +4,8 @@ import { ApolloProvider } from "@apollo/client/react"; import './index.css' import App from './App.tsx' import { apolloClient } from "./apolloClient"; -import './tema.css' +import './theme.css' + createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/tema.css b/frontend/src/tema.css deleted file mode 100644 index 5258fae..0000000 --- a/frontend/src/tema.css +++ /dev/null @@ -1,90 +0,0 @@ -/* Handlelappen – notatbok: farger og skrift fra Figma (siden «Grunnlag») */ -@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'); - -:root { - /* Farger */ - --hvit: #ffffff; /* bakgrunn og lapper */ - --blekk: #141414; /* tekst og linjer */ - --burgunder: #65081f; /* håndskrift, merke, kant på kort */ - --bringebaer: #d3045d; /* knapper, kommentarer */ - --rosa: #fce8f0; /* lapper, markering */ - - /* Skrift */ - --font-ui: 'Geist', system-ui, sans-serif; - --font-mono: 'Geist Mono', ui-monospace, monospace; - --font-aksent: 'Instrument Serif', Georgia, serif; - --font-lapp: 'Kalam', cursive; -} - -body { - margin: 0; - background: var(--hvit); - color: var(--blekk); - font-family: var(--font-ui); -} - -/* Etiketter som «01 · KATEGORIER» */ -.etikett { - font-family: var(--font-mono); - font-size: 12px; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -/* Kursiv aksent i overskrifter, f.eks.

          Handlelappen

          */ -h1 em, h2 em { - font-family: var(--font-aksent); - font-style: italic; - font-weight: 400; - color: var(--burgunder); -} - -/* Knapper */ -.knapp { - background: var(--bringebaer); - color: var(--hvit); - border: none; - border-radius: 8px; - padding: 10px 16px; - font: 500 15px var(--font-ui); - cursor: pointer; -} - -/* Rutemønster bak kategoriene */ -.rutemonster { - background-color: #fafafa; - background-image: - linear-gradient(#eeeeee 1px, transparent 1px), - linear-gradient(90deg, #eeeeee 1px, transparent 1px); - background-size: 24px 24px; -} - -/* Kategorikort: burgunder kant og skarp skygge */ -.kort { - background: var(--hvit); - border: 1px solid var(--burgunder); - border-radius: 10px; - box-shadow: 3px 3px 0 var(--burgunder); - padding: 16px; - transition: box-shadow 0.15s, transform 0.15s; -} -.kort:hover { - border-width: 2px; - box-shadow: 5px 5px 0 var(--burgunder); - transform: translate(-1px, -1px); -} - -/* Handlelappen: hvit lapp med rosa linjer, rød marg og tynn svart kontur */ -.lapp { - font-family: var(--font-lapp); - color: var(--burgunder); - background-color: var(--hvit); - background-image: repeating-linear-gradient( - transparent 0 31px, var(--rosa) 31px 32px); - border: 1px solid var(--blekk); - border-left: 3px solid var(--bringebaer); - padding: 16px 16px 16px 28px; - line-height: 32px; - box-shadow: 6px 6px 0 var(--rosa); - transform: rotate(-1deg); -} 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 From 87f296e332947c65d8b8906160e34c1f84c0bd18 Mon Sep 17 00:00:00 2001 From: Ingrihka Date: Mon, 12 Oct 2026 15:28:05 +0200 Subject: [PATCH 12/12] docs: full datadump og seed i README (#34) --- README.md | 208 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 146 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 617c6a1..1b207e9 100644 --- a/README.md +++ b/README.md @@ -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) @@ -105,14 +111,14 @@ Backenden kjører på port 3001. Når backenden kjører lokalt, kan du åpne htt ### 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. | +| 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`. @@ -170,10 +176,21 @@ 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 } @@ -181,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 @@ -254,7 +288,8 @@ README-en til første innlevering er skrevet med hjelp fra Claude (KI), basert p - **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**. -- **Kassalapp API-nøkkel** trengs bare hvis du skal hente nye data. En ferdig dump ligger i `backend/data/kassalapp-dump.json`, så du kan sette opp databasen uten nøkkel. +- **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 @@ -288,35 +323,71 @@ 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. | +| 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 | -| --- | --- | --- | +| 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 -Start PostgreSQL, lag databasen og kjør skjemaet. Kommandoene kjøres fra `backend/`: +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 ``` -- `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. -- `npm run seed` fyller databasen fra `data/kassalapp-dump.json`. Den kan også kjøres flere ganger, siden eksisterende varer og priser oppdateres i stedet for å dupliseres. Den må kjøres fra `backend/`, fordi filstien er relativ. +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;" +``` -Når seeden er ferdig, skriver den ut hvor mange kategorier, varer og priser som ble lagt inn. +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 @@ -342,7 +413,9 @@ npm run dev ### Hente nye data fra Kassalapp (valgfritt) -Dette trengs bare hvis dataene skal oppdateres. Legg inn `KASSALAPP_API_KEY` i `backend/.env` og kjør: +> **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 @@ -354,28 +427,29 @@ 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. | +| 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/`. | +| 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/`. | -| `extension "pg_trgm" is not available` | Installer `postgresql-contrib` (Ubuntu) eller bruk en PostgreSQL-installasjon som inkluderer den. | +| `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 @@ -394,6 +468,16 @@ curl -s http://:3001/ \ -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: ```