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); diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..ae93746 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_GRAPHQL_URL=http://localhost:3001/ \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..438657a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.env # Editor directories and files .vscode/* diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 647ad91..fda09ff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,8 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@apollo/client": "^4.3.2", + "graphql": "^16.14.2", "react": "^19.2.8", "react-dom": "^19.2.8" }, @@ -30,6 +32,43 @@ "vite": "^8.3.0" } }, + "node_modules/@apollo/client": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.2.tgz", + "integrity": "sha512-ra6f8NhP4b5Wznw6VpRGOGF6Q6a8rORToQeIx5BMC5Rc7mRVZ9wCN+01ynIZPjmlGPNif2Lqt0LRD+futfNgIw==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@wry/caches": "^1.0.0", + "@wry/equality": "^0.5.6", + "@wry/trie": "^0.5.0", + "graphql-tag": "^2.12.6", + "optimism": "^0.18.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "graphql": "^16.0.0 || ^17.0.0", + "graphql-ws": "^5.5.5 || ^6.0.3", + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "react-dom": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "rxjs": "^7.3.0", + "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" + }, + "peerDependenciesMeta": { + "graphql-ws": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "subscriptions-transport-ws": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -422,6 +461,15 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1252,6 +1300,54 @@ } } }, + "node_modules/@wry/caches": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", + "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/context": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz", + "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/equality": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", + "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/trie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", + "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -1822,6 +1918,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.7.tgz", + "integrity": "sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/hashery": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", @@ -2338,6 +2458,18 @@ "node": ">=18" } }, + "node_modules/optimism": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz", + "integrity": "sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==", + "license": "MIT", + "dependencies": { + "@wry/caches": "^1.0.0", + "@wry/context": "^0.7.0", + "@wry/trie": "^0.5.0", + "tslib": "^2.3.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2552,6 +2684,16 @@ "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -2631,6 +2773,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9ec1dd6..6202da0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,8 @@ "preview": "vite preview" }, "dependencies": { + "@apollo/client": "^4.3.2", + "graphql": "^16.14.2", "react": "^19.2.8", "react-dom": "^19.2.8" }, diff --git a/frontend/src/App.css b/frontend/src/App.css index f90339d..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 9ca8e8d..9461e05 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,122 +1,123 @@ -import { useState } from 'react' -import heroImg from './assets/hero.png' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import './App.css' +import { gql } from "@apollo/client"; +import type { TypedDocumentNode } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; +import { useEffect, useState } from "react"; +import "./App.css"; + +type Price = { + storeCode: string; + price: number; +}; + +type Product = { + ean: string; + name: string; + brand: string | null; + imageUrl: string | null; + prices: Price[]; +}; + +type ProductsData = { + products: Product[]; +}; + +type ProductsVars = { + search?: string; + limit: number; +}; + +const PRODUCTS_QUERY: TypedDocumentNode = gql` + query Products($search: String, $limit: Int) { + products(search: $search, limit: $limit) { + ean + name + brand + imageUrl + prices { + storeCode + price + } + } + } +`; + +// Finner laveste pris blant butikkene, eller null hvis varen ikke har pris +function lowestPrice(prices: Price[]): number | null { + if (prices.length === 0) return null; + return Math.min(...prices.map((p) => p.price)); +} function App() { - const [count, setCount] = useState(0) + const [input, setInput] = useState(""); + const [search, setSearch] = useState(""); + + // Debounce: venter 400 ms etter siste tastetrykk før vi søker, + // slik at vi ikke sender en request for hver bokstav. + useEffect(() => { + const timer = setTimeout(() => setSearch(input.trim()), 400); + return () => clearTimeout(timer); + }, [input]); + + const { data, loading, error } = useQuery(PRODUCTS_QUERY, { + variables: { search: search || undefined, limit: 20 }, + }); return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

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

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) +
+

Handlelappen

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

Laster varer...

} + + {error &&

Kunne ikke hente varer: {error.message}

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

Ingen varer funnet

+ )} + + {data && data.products.length > 0 && ( + + )} +
+ ); } -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( - + + + , -) +);