diff --git a/README.md b/README.md index 9d6f133..bb881a2 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,14 @@ Hva du får opp når du filtrerer på en bestemt butikk hvis den har produktet o ### Produktside og meny +Produktsiden er utviklet med utgangspunkt i Figma-designet. Den henter produktnavn, merke, kategori, produktbilde og butikkpriser fra GraphQL-backenden basert på produktets EAN-kode. +Butikkprisene sorteres fra lavest til høyest, med tydelig markering av billigste butikk og prisforskjeller. Dersom bilde eller priser mangler, vises en alternativ melding. +Siden støtter både light mode og dark mode ved hjelp av prosjektets eksisterende temavariabler. + +Ingredienser, allergener og næringsinnhold inngår i designet, men er foreløpig ikke tilgjengelige gjennom GraphQL. Handlelistekontrollene er implementert med lokal React-state, men er ennå ikke koblet til backend. 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. + ### 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. @@ -111,6 +117,23 @@ Backenden kjører på port 3001. Når backenden kjører lokalt, kan du åpne htt En `Product` har feltene `ean`, `name`, `brand`, `imageUrl`, `categoryId`, `category` og `prices`. Hver pris har `storeCode`, `price`, `unitPrice` og `url`. +### GraphQL i frontend – produktdetaljer + +Frontend bruker to ulike GraphQL-spørringer for produkter: + +- `products(search, limit, ...)` brukes til å hente flere produkter til søk og produktoversikter. +- `product(ean)` brukes til å hente informasjon om ett bestemt produkt på produktdetaljsiden. + +Dette er et bevisst valg for å skille mellom produktsøk og detaljvisning. Det gjør at detaljsiden kan hente informasjon om et bestemt produkt uten å måtte søke gjennom en produktliste. + +Produktdetaljsiden bruker Apollo Clients `useQuery` og en egen spørring definert i `frontend/src/pages/productDetails/productQueries.ts`. + +Spørringen henter `ean`, `name`, `brand`, `imageUrl`, `category` og `prices`, inkludert butikkode, pris og enhetspris. + +Prisene sorteres i frontend for det aktuelle produktet. Apollo Client tilbyr caching, men vi har foreløpig ikke dokumentert eller målt noen reduksjon i nettverkskall mellom produktlisten og detaljsiden. + +Ved lasting, feil eller manglende produkt vises egne tilstander, slik at siden ikke forsøker å vise informasjon som ikke finnes. + **Eksempel: søk etter melk** ```graphql @@ -257,11 +280,28 @@ Lister med mange elementer, som søkeresultater og tidligere handlelister, retur - 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)). +### Produktdetaljside – implementert etter innlevering 2 + +- Produktdetaljside utviklet med React, TypeScript og CSS basert på Figma-designet. +- GraphQL-integrasjon for henting av produkter basert på EAN. +- Dynamisk visning av produktnavn, merke, kategori, bilde og butikkpriser. +- Prissammenligning med sortering fra billigst til dyrest. +- Håndtering av lasting, feil, manglende produkter, priser og bilder. +- Støtte for light mode og dark mode gjennom felles temavariabler. +- Testet manuelt mot backend. `npm run build` er gjennomført uten feil. + +**Gjenstår:** +- Hente ingredienser, allergener og næringsinnhold fra backend. +- Koble handlelistekontrollene til backend. +- Integrere navigasjon fra søkeresultater til produktdetaljsiden. + + **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. +- Utvide den eksisterende produktdetaljsiden med ingredienser, + allergener og næringsinnhold fra backend. - 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. diff --git a/backend/src/index.ts b/backend/src/index.ts index def069b..f5aca64 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -10,6 +10,12 @@ const typeDefs = `#graphql name: String! } + type FilterCount { + value: String! + label: String! + count: Int! + } + type Price { storeCode: String! price: Float! @@ -30,17 +36,33 @@ const typeDefs = `#graphql type Query { products( search: String - category: ID + categories: [ID!] + storeCodes: [String!] + sort: String = "relevance" limit: Int = 20 offset: Int = 0 ): [Product!]! + productsCount( + search: String + categories: [ID!] + storeCodes: [String!] + ): Int! + product(ean: String!): Product categories: [Category!]! shoppingList(id: ID!): ShoppingList + categoryFilterCounts( + search: String + storeCodes: [String!] + ): [FilterCount!]! + storeFilterCounts( + search: String + categories: [ID!] + ): [FilterCount!]! } type ShoppingListItem { @@ -72,6 +94,124 @@ const typeDefs = `#graphql const resolvers = { Query: { + categoryFilterCounts: async ( + _parent: unknown, + args: { + search?: string; + storeCodes?: string[]; + }, + ) => { + const search = args.search ?? null; + const storeCodes = args.storeCodes ?? []; + + const result = await pool.query( + ` + WITH RECURSIVE category_tree AS ( + SELECT + id, + name, + parent_id, + id AS root_id, + name AS root_name + FROM categories + WHERE parent_id IS NULL + + UNION + + SELECT + c.id, + c.name, + c.parent_id, + ct.root_id, + ct.root_name + FROM categories c + INNER JOIN category_tree ct + ON c.parent_id = ct.id + ) + SELECT + ct.root_id::text AS value, + ct.root_name AS label, + COUNT(DISTINCT p.ean)::integer AS count + FROM category_tree ct + INNER JOIN products p + ON p.category_id = ct.id + WHERE + ( + $1::text IS NULL + OR p.name ILIKE '%' || $1 || '%' + ) + AND ( + cardinality($2::text[]) = 0 + OR EXISTS ( + SELECT 1 + FROM prices pr + WHERE pr.ean = p.ean + AND pr.store_code = ANY($2::text[]) + ) + ) + GROUP BY ct.root_id, ct.root_name + HAVING COUNT(DISTINCT p.ean) > 0 + ORDER BY count DESC, label + `, + [search, storeCodes], + ); + + return result.rows; + }, + + + storeFilterCounts: async ( + _parent: unknown, + args: { + search?: string; + categories?: string[]; + }, + ) => { + const search = args.search ?? null; + const categories = args.categories ?? []; + + const result = await pool.query( + ` + WITH RECURSIVE selected_categories AS ( + SELECT id + FROM categories + WHERE id = ANY($2::integer[]) + + UNION + + SELECT c.id + FROM categories c + INNER JOIN selected_categories sc + ON c.parent_id = sc.id + ) + SELECT + pr.store_code AS value, + pr.store_code AS label, + COUNT(DISTINCT p.ean)::integer AS count + FROM prices pr + INNER JOIN products p + ON p.ean = pr.ean + WHERE + ( + $1::text IS NULL + OR p.name ILIKE '%' || $1 || '%' + ) + AND ( + cardinality($2::integer[]) = 0 + OR p.category_id IN ( + SELECT id FROM selected_categories + ) + ) + GROUP BY pr.store_code + HAVING COUNT(DISTINCT p.ean) > 0 + ORDER BY count DESC, value + `, + [search, categories], + ); + + return result.rows; + }, + categories: async () => { const result = await pool.query(` SELECT @@ -103,44 +243,190 @@ const resolvers = { return result.rows[0] ?? null; }, - + products: async ( _parent: unknown, args: { search?: string; - category?: string; + categories?: string[]; + storeCodes?: string[]; + sort?: 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 categories = args.categories ?? []; + const storeCodes = args.storeCodes ?? []; + + const allowedSorts = [ + "relevance", + "price_asc", + "price_desc", + ]; + + const sort = allowedSorts.includes(args.sort ?? "") + ? args.sort + : "relevance"; + + const limit = Math.min( + Math.max(args.limit ?? 20, 1), + 100, + ); + + const offset = Math.max(args.offset ?? 0, 0); const result = await pool.query( ` + WITH RECURSIVE selected_categories AS ( + SELECT id + FROM categories + WHERE id = ANY($2::integer[]) + + UNION + + SELECT c.id + FROM categories c + INNER JOIN selected_categories sc + ON c.parent_id = sc.id + ), + matching_products AS ( + SELECT + p.ean, + p.name, + p.brand, + p.image_url AS "imageUrl", + p.category_id AS "categoryId", + ( + SELECT MIN(pr.price) + FROM prices pr + WHERE pr.ean = p.ean + AND ( + cardinality($3::text[]) = 0 + OR pr.store_code = ANY($3::text[]) + ) + ) AS sort_price + FROM products p + WHERE + ( + $1::text IS NULL + OR p.name ILIKE '%' || $1 || '%' + ) + AND ( + cardinality($2::integer[]) = 0 + OR p.category_id IN ( + SELECT id FROM selected_categories + ) + ) + AND ( + cardinality($3::text[]) = 0 + OR EXISTS ( + SELECT 1 + FROM prices pr + WHERE pr.ean = p.ean + AND pr.store_code = ANY($3::text[]) + ) + ) + ) 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 + "imageUrl", + "categoryId" + FROM matching_products + ORDER BY + CASE + WHEN $6::text = 'relevance' THEN + CASE + WHEN LOWER(name) = LOWER($1::text) THEN 0 + WHEN name ILIKE $1::text || '%' THEN 1 + ELSE 2 + END + END ASC NULLS LAST, + + CASE + WHEN $6::text = 'price_asc' + THEN sort_price + END ASC NULLS LAST, + + CASE + WHEN $6::text = 'price_desc' + THEN sort_price + END DESC NULLS LAST, + + name ASC, + ean ASC + LIMIT $4 + OFFSET $5 `, - [search, category, limit, offset], + [ + search, + categories, + storeCodes, + limit, + offset, + sort, + ], ); return result.rows; }, + productsCount: async ( + _parent: unknown, + args: { + search?: string; + categories?: string[]; + storeCodes?: string[]; + }, + ) => { + const search = args.search ?? null; + const categories = args.categories ?? []; + const storeCodes = args.storeCodes ?? []; + + const result = await pool.query( + ` + WITH RECURSIVE selected_categories AS ( + SELECT id + FROM categories + WHERE id = ANY($2::integer[]) + + UNION + + SELECT c.id + FROM categories c + INNER JOIN selected_categories sc + ON c.parent_id = sc.id + ) + SELECT COUNT(*)::integer AS count + FROM products p + WHERE + ( + $1::text IS NULL + OR p.name ILIKE '%' || $1 || '%' + ) + AND ( + cardinality($2::integer[]) = 0 + OR p.category_id IN ( + SELECT id FROM selected_categories + ) + ) + AND ( + cardinality($3::text[]) = 0 + OR EXISTS ( + SELECT 1 + FROM prices pr + WHERE pr.ean = p.ean + AND pr.store_code = ANY($3::text[]) + ) + ) + `, + [search, categories, storeCodes], + ); + + return result.rows[0].count; + }, shoppingList: async ( _parent: unknown, diff --git a/frontend/index.html b/frontend/index.html index 0fca6f0..9db57f1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,17 @@ - + - - - frontend + + + Handlelappen
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fda09ff..b4aa3b8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,8 @@ "@apollo/client": "^4.3.2", "graphql": "^16.14.2", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router": "^8.4.0" }, "devDependencies": { "@babel/core": "^7.29.7", @@ -620,6 +621,12 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@remix-run/route-pattern": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz", + "integrity": "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", @@ -1510,6 +1517,12 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2650,6 +2663,28 @@ "react": "^19.3.0" } }, + "node_modules/react-router": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.4.0.tgz", + "integrity": "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==", + "license": "MIT", + "dependencies": { + "@remix-run/route-pattern": "^0.22.1", + "cookie-es": "^3.1.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "react": ">=19.2.7", + "react-dom": ">=19.2.7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/rolldown": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6202da0..d63f188 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,8 @@ "@apollo/client": "^4.3.2", "graphql": "^16.14.2", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router": "^8.4.0" }, "devDependencies": { "@babel/core": "^7.29.7", diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 6893eb1..43c1356 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1 +1,62 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css index 1223237..e5a8cb3 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,70 +1,27 @@ -/* Hjemskjerm – styling etter Figma (Handlelappen – notatbok). - Farger og skrift kommer fra styles/theme.css. */ +/* ========================================= + APP – FELLES LAYOUT + Issue #51 + ========================================= */ +html, +body, #root { - max-width: 1100px; - margin: 0 auto; - padding: 48px 24px; -} - -/* Søkefelt + knapp */ -.search { - display: flex; - gap: 0.75rem; - justify-content: center; - align-items: center; - margin-bottom: 1.5rem; -} - -.search input { - flex: 1; - max-width: 480px; - padding: 10px 14px; - font: 15px var(--font-ui); - color: var(--text-primary); - background: var(--bg-surface); - border: 1px solid var(--border-strong); - border-radius: 8px; -} - -.search input:focus { - outline: 2px solid var(--text-accent); - outline-offset: 2px; + width: 100%; + min-width: 0; + min-height: 100%; + margin: 0; + padding: 0; } -.search button { - background: var(--bg-primary); - color: var(--text-on-dark); +#root { + max-width: none; border: none; - border-radius: 8px; - padding: 10px 16px; - font: 500 15px var(--font-ui); - cursor: pointer; -} - -/* Produktliste */ -.product-list { - list-style: none; - padding: 0; - margin: 0; text-align: left; + display: block; } -.product { - display: flex; - gap: 1rem; - align-items: center; - padding: 0.75rem 0; - border-bottom: 1px solid var(--border-hairline); +body { + background: var(--bg-page); + color: var(--text-primary); + font-family: var(--font-ui); } - -.product img, -.no-image { - width: 64px; - height: 64px; - object-fit: contain; - background: var(--bg-surface); - border: 1px solid var(--border-brand); - border-radius: 6px; - flex-shrink: 0; -} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ab7dfc1..ed57d82 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,123 +1,150 @@ -import { gql } from "@apollo/client"; -import type { TypedDocumentNode } from "@apollo/client"; -import { useQuery } from "@apollo/client/react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; +import NavigationMenu from "./components/navigationMenu/NavigationMenu"; + +import { + BrowserRouter, + Routes, + Route, + useLocation, + useNavigate, +} from "react-router"; + +import Header from "./components/header/Header"; +import HomePage from "./pages/home/HomePage"; +import SearchResultsPage from "./pages/SearchResults/SearchResultsPage"; + +import { + loadSearchPreferences, + saveSearchPreferences, +} from "./pages/SearchResults/searchPreferences"; + +import { createCategoryUrl } from "./pages/home/categoryNavigation"; + 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 - } - } - } -`; +function AppLayout() { + const location = useLocation(); + const navigate = useNavigate(); + const [isMenuOpen, setIsMenuOpen] = useState(false); -// 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)); -} + const isHome = location.pathname === "/"; + + const searchParams = new URLSearchParams(location.search); + const searchValue = searchParams.get("q") ?? ""; + + // Felles søkefunksjon for header og startside. + const handleSearch = (value: string) => { + const search = value.trim(); -function App() { - const [input, setInput] = useState(""); - const [search, setSearch] = useState(""); + if (!search) return; - // 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 preferences = loadSearchPreferences(); - const { data, loading, error } = useQuery(PRODUCTS_QUERY, { - variables: { search: search || undefined, limit: 20 }, - }); + if (preferences.searchTerm !== search) { + saveSearchPreferences({ + searchTerm: search, + selectedCategories: [], + selectedStores: [], + sort: "relevance", + currentPage: 1, + }); + } + + navigate(`/search?q=${encodeURIComponent(search)}`); + }; + + // Navigasjon fra kategorikort på startsiden. + const handleCategorySelect = (categoryIds: string[]) => { + if (categoryIds.length === 0) return; + + saveSearchPreferences({ + searchTerm: "", + selectedCategories: categoryIds, + selectedStores: [], + sort: "relevance", + currentPage: 1, + }); + + navigate(createCategoryUrl(categoryIds)); + + // Start øverst når en kategori åpnes. + window.scrollTo({ + top: 0, + left: 0, + behavior: "instant", + }); + }; + + const handleOpenCatalog = () => { + saveSearchPreferences({ + searchTerm: "", + selectedCategories: [], + selectedStores: [], + sort: "relevance", + currentPage: 1, + }); + + window.scrollTo({ + top: 0, + left: 0, + behavior: "instant", + }); + }; return ( -
-

Handlelappen

- -
- - setInput(e.target.value)} - placeholder="F.eks. melk" + <> +
setIsMenuOpen(true)} + isMenuOpen={isMenuOpen} + /> + + setIsMenuOpen(false)} + onNavigateCatalog={handleOpenCatalog} + /> + + + + } /> -
- - {loading &&

Laster varer...

} - - {error &&

Kunne ikke hente varer: {error.message}

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

Ingen varer funnet

- )} - - {data && data.products.length > 0 && ( - - )} -
+ + + } + /> + + +

Fant ikke siden

+

Siden du leter etter finnes ikke.

+ + } + /> + + ); } -export default App; \ No newline at end of file +export default function App() { + return ( + + + + ); +} diff --git a/frontend/src/components/header/Header.css b/frontend/src/components/header/Header.css index ebb1e61..b8d28c4 100644 --- a/frontend/src/components/header/Header.css +++ b/frontend/src/components/header/Header.css @@ -1,45 +1,213 @@ -/* Header – fra Figma (Components → Header) - Inneholder Logo, enten SearchField eller navigasjon, og en Button (variant dark, size s). */ + +/* ========================================= + HEADER – ISSUE #50 + Felles header for Handlelappen + ========================================= */ + +/* 1. Grunnstruktur */ + .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); + display: grid; + grid-template-columns: + minmax(180px, 1fr) + minmax(0, 1.5fr) + minmax(180px, 1fr); + + align-items: center; + gap: 24px; + + width: 100%; + max-width: none; + min-height: 64px; + padding: 12px 40px; + box-sizing: border-box; + + background: var(--bg-page); + border-bottom: 1px solid var(--border-hairline); + color: var(--text-primary); } -.header .search-field { - flex: 1; +.header__logo { + display: flex; + align-items: center; + min-width: 0; } +/* 2. Navigasjon – hjemmeside */ + .header__nav { - display: flex; - gap: 32px; + display: flex; + justify-content: center; + align-items: center; + gap: 32px; + min-width: 0; } .header__link { - font: 500 11px var(--font-mono); - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--text-secondary); - text-decoration: none; + font: 500 11px var(--font-mono); + letter-spacing: 0.06em; + text-transform: uppercase; + + color: var(--text-secondary); + text-decoration: none; + white-space: nowrap; } .header__link:hover { - color: var(--text-accent); + color: var(--text-accent); +} + +.header__link:focus-visible { + outline: 2px solid var(--text-accent); + outline-offset: 5px; + border-radius: 2px; +} + +/* 3. Søkefelt – øvrige sider */ + +.header__search { + display: flex; + align-items: center; + gap: 12px; + + width: 100%; + max-width: 610px; + height: 40px; + padding: 0 14px; + box-sizing: border-box; + + justify-self: center; + + background: var(--bg-surface); + border: 1px solid transparent; + border-radius: 8px; +} + +.header__search:focus-within { + border-color: var(--text-accent); +} + +.header__search-icon { + display: flex; + width: 18px; + height: 18px; + flex-shrink: 0; + color: var(--text-muted); +} + +.header__search-icon svg { + width: 100%; + height: 100%; +} + +.header__search-input { + flex: 1; + min-width: 0; + width: 100%; + border: none; + outline: none; + background: transparent; + + color: var(--text-primary); + font: 400 13px var(--font-ui); +} + +.header__search-input::placeholder { + color: var(--text-muted); } -/* Mobil: mindre luft på sidene */ -@media (max-width: 600px) { - .header { - gap: 12px; - padding: 0 16px; - } +.header__search-input:disabled { + cursor: not-allowed; +} + +/* 4. Handlinger */ + +.header__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + min-width: 0; +} + +.header__list-button { + white-space: nowrap; + justify-content: center; +} + +.header__list-button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +/* 5. Nettbrett */ + +@media (max-width: 1000px) { + .header { + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 16px; + padding-left: 24px; + padding-right: 24px; + } + + .header__nav { + gap: 16px; + } +} + +/* 6. Mobil */ + +@media (max-width: 700px) { + .header { + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + padding: 10px 16px; + } + + .header__nav, + .header__search { + display: none; + } + + .header__actions { + gap: 8px; + } + + .header__list-button { + padding-left: 10px; + padding-right: 10px; + } +} + +/* 7. Ekstra små skjermer */ + +/* 7. Ekstra små skjermer */ + +@media (max-width: 360px) { + .header { + padding-left: 10px; + padding-right: 10px; + gap: 6px; + } + + .header__logo .logo { + gap: 5px; + font-size: 13px; + } + + .header__logo .logo__icon { + width: 22px; + height: 22px; + } + + .header__actions { + gap: 6px; + } + + .header__list-button { + font-size: 10px; + padding-left: 7px; + padding-right: 7px; + } +} - .header__nav { - display: none; - } -} \ No newline at end of file diff --git a/frontend/src/components/header/Header.tsx b/frontend/src/components/header/Header.tsx new file mode 100644 index 0000000..278d580 --- /dev/null +++ b/frontend/src/components/header/Header.tsx @@ -0,0 +1,128 @@ +import { Link } from "react-router"; + +import { useState } from "react"; +import type { FormEvent } from "react"; + +import Logo from "../logo/Logo"; +import MenuButton from "../menuButton/MenuButton"; + +import "../button/Button.css"; +import "./Header.css"; + +type HeaderProps = { + variant?: "home" | "search"; + searchValue?: string; + onOpenCatalog?: () => void; + onSearch?: (value: string) => void; + onOpenList?: () => void; + onOpenMenu?: () => void; + isMenuOpen?: boolean; + listCount?: number; +}; + +export default function Header({ + variant = "home", + searchValue = "", + onOpenCatalog, + onSearch, + onOpenList, + onOpenMenu, + isMenuOpen = false, + listCount = 0, +}: HeaderProps) { + const [searchInput, setSearchInput] = useState(searchValue); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + const value = searchInput.trim(); + + if (value && onSearch) { + onSearch(value); + } + }; + + return ( +
+
+ +
+ + {variant === "home" ? ( + + ) : ( +
+ + + + setSearchInput(event.target.value) + } + disabled={!onSearch} + /> +
+ )} + +
+ + + +
+
+ ); +} diff --git a/frontend/src/components/logo/Logo.css b/frontend/src/components/logo/Logo.css index c508ff2..ed2c624 100644 --- a/frontend/src/components/logo/Logo.css +++ b/frontend/src/components/logo/Logo.css @@ -1,35 +1,96 @@ /* 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; + 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; +/* Ikon: handlelapp som tegnes opp med haker */ +.logo__icon { + width: 28px; + height: 28px; + overflow: visible; + flex-shrink: 0; +} +.logo__back { + fill: var(--bg-brand); +} +.logo__paper { + fill: var(--note-paper); + stroke: var(--border-strong); + stroke-width: 1.2; +} +.logo__margin { + stroke: var(--note-margin); + stroke-width: 1; +} +.logo__line { + stroke: var(--text-muted); + stroke-width: 1.6; + stroke-linecap: round; } -.logo__mark::before, -.logo__mark::after { - content: ""; - position: absolute; - inset: 3px; - border-radius: 2px; +.logo__check { + fill: none; + stroke: var(--text-accent); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 8; + stroke-dashoffset: 8; + animation: logo-draw 0.35s ease-out 0.3s forwards; +} +.logo__check--2 { + animation-delay: 0.75s; } -.logo__mark::before { - background: var(--bg-brand); - transform: rotate(-6deg); +.logo__sheet { + transform-box: fill-box; + transform-origin: center; + transition: transform 0.3s ease; +} +.logo:hover .logo__sheet { + transform: rotate(-6deg); +} +.logo:hover .logo__check { + animation-name: logo-draw-again; } -.logo__mark::after { - background: var(--bg-primary); - transform: rotate(8deg) translate(-2px, -2px); +@keyframes logo-draw { + to { + stroke-dashoffset: 0; + } +} +@keyframes logo-draw-again { + from { + stroke-dashoffset: 8; + } + to { + stroke-dashoffset: 0; + } +} + +/* Respekter brukere som har skrudd av animasjoner */ +@media (prefers-reduced-motion: reduce) { + .logo__check { + animation: none; + stroke-dashoffset: 0; + } + .logo__sheet { + transition: none; + } +} + +/* ========================================= + UNIVERSAL UTFORMING – FOKUSMARKERING + ========================================= */ + +.logo:focus-visible { + outline: 2px solid var(--text-accent); + outline-offset: 4px; + border-radius: 4px; } \ No newline at end of file diff --git a/frontend/src/components/logo/Logo.tsx b/frontend/src/components/logo/Logo.tsx new file mode 100644 index 0000000..d1c813e --- /dev/null +++ b/frontend/src/components/logo/Logo.tsx @@ -0,0 +1,43 @@ +import "./Logo.css"; + +function Logo() { + return ( + + + Handlelappen + + ); +} + +export default Logo; diff --git a/frontend/src/components/menuButton/MenuButton.css b/frontend/src/components/menuButton/MenuButton.css new file mode 100644 index 0000000..97222e2 --- /dev/null +++ b/frontend/src/components/menuButton/MenuButton.css @@ -0,0 +1,70 @@ + +/* ========================================= + MENU BUTTON – ISSUE #50 + Gjenbrukbar menyknapp + ========================================= */ + +.menu-button { + display: inline-flex; + align-items: center; + justify-content: center; + + width: 40px; + height: 40px; + padding: 0; + + flex-shrink: 0; + + background: var(--bg-surface); + border: 1px solid var(--border-hairline); + border-radius: 8px; + + color: var(--text-primary); + cursor: pointer; + + transition: + background-color 0.2s ease, + border-color 0.2s ease; +} + +.menu-button:hover:not(:disabled) { + background: var(--bg-tint); + border-color: var(--text-accent); +} + +.menu-button:focus-visible { + outline: 2px solid var(--text-accent); + outline-offset: 3px; +} + +.menu-button:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +.menu-button__icon { + display: flex; + flex-direction: column; + justify-content: center; + gap: 4px; + + width: 18px; + height: 18px; +} + +.menu-button__icon span { + display: block; + width: 100%; + height: 2px; + + background: currentColor; + border-radius: 2px; +} + +/* Mobil */ +@media (max-width: 600px) { + .menu-button { + width: 38px; + height: 38px; + } +} diff --git a/frontend/src/components/menuButton/MenuButton.tsx b/frontend/src/components/menuButton/MenuButton.tsx new file mode 100644 index 0000000..ab07e55 --- /dev/null +++ b/frontend/src/components/menuButton/MenuButton.tsx @@ -0,0 +1,41 @@ + +import "./MenuButton.css"; + +type MenuButtonProps = { + onClick?: () => void; + isOpen?: boolean; + disabled?: boolean; +}; + +export default function MenuButton({ + onClick, + isOpen = false, + disabled = false, +}: MenuButtonProps) { + const isDisabled = disabled || !onClick; + + return ( + + ); +} + diff --git a/frontend/src/components/navigationMenu/NavigationMenu.css b/frontend/src/components/navigationMenu/NavigationMenu.css new file mode 100644 index 0000000..26cc47e --- /dev/null +++ b/frontend/src/components/navigationMenu/NavigationMenu.css @@ -0,0 +1,148 @@ +.navigation-menu-dialog { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + max-width: none; + max-height: none; + margin: 0; + padding: 0; + border: none; + background: transparent; + color: var(--text-primary); + overflow: visible; +} + +.navigation-menu-dialog::backdrop { + background: rgba(0, 0, 0, 0.45); +} + +.navigation-menu { + position: absolute; + inset: 0 auto 0 0; + display: flex; + flex-direction: column; + width: min(320px, 90vw); + height: 100%; + box-sizing: border-box; + padding: 24px 20px; + background: var(--bg-page); + border-right: 1px solid var(--border-hairline); + color: var(--text-primary); + overflow-y: auto; +} + +.navigation-menu__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 36px; +} + +.navigation-menu__close { + width: 36px; + height: 36px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-primary); + font-size: 28px; + cursor: pointer; +} + +.navigation-menu__close:hover { + background: var(--bg-tint); +} + +.navigation-menu__close:focus-visible, +.navigation-menu__links a:focus-visible { + outline: 2px solid var(--text-accent); + outline-offset: 3px; +} + +.navigation-menu__eyebrow { + margin: 0 0 12px; + color: var(--text-accent); + font: 500 11px var(--font-mono); + letter-spacing: 0.06em; +} + +.navigation-menu__links { + display: flex; + flex-direction: column; + gap: 4px; +} + +.navigation-menu__links a, +.navigation-menu__unavailable { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 0 10px; + border-radius: 6px; + font: 500 13px var(--font-ui); + text-decoration: none; + color: var(--text-primary); +} + +.navigation-menu__links a:hover { + background: var(--bg-tint); + color: var(--text-accent); +} + +.navigation-menu__links a span:first-child, +.navigation-menu__unavailable span:first-child { + color: var(--text-accent); + font: 500 10px var(--font-mono); +} + +.navigation-menu__links a span:last-child { + color: var(--text-muted); +} + +.navigation-menu__unavailable { + opacity: 0.55; + cursor: default; +} + +.navigation-menu__unavailable span:last-child { + font: 500 10px var(--font-mono); + color: var(--text-muted); +} + +.navigation-menu__divider { + height: 1px; + margin: 12px 0; + background: var(--border-hairline); +} + +.navigation-menu__footer { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: auto; + padding: 20px 10px 0; + color: var(--text-accent); + font: 500 12px var(--font-mono); +} + +.navigation-menu__footer span { + color: var(--text-muted); + font-size: 10px; +} + +@media (max-width: 600px) { + .navigation-menu { + width: min(320px, 92vw); + padding: 20px 16px; + } +} + +@media (prefers-reduced-motion: reduce) { + .navigation-menu { + transition: none; + } +} diff --git a/frontend/src/components/navigationMenu/NavigationMenu.tsx b/frontend/src/components/navigationMenu/NavigationMenu.tsx new file mode 100644 index 0000000..3f36a09 --- /dev/null +++ b/frontend/src/components/navigationMenu/NavigationMenu.tsx @@ -0,0 +1,142 @@ + +import { useEffect, useRef } from "react"; +import { Link } from "react-router"; + +import Logo from "../logo/Logo"; +import "./NavigationMenu.css"; + +type NavigationMenuProps = { + isOpen: boolean; + onClose: () => void; + onNavigateCatalog: () => void; +}; + +export default function NavigationMenu({ + isOpen, + onClose, + onNavigateCatalog, +}: NavigationMenuProps) { + const dialogRef = useRef(null); + const closeButtonRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + + if (isOpen) { + if (!dialog.open) { + dialog.showModal(); + } + + closeButtonRef.current?.focus(); + } else if (dialog.open) { + dialog.close(); + } + }, [isOpen]); + + // Hold React-state synkronisert når dialogen lukkes, + // for eksempel med Escape. + const handleClose = () => { + onClose(); + }; + + // Lukk når brukeren klikker utenfor selve menyen. + const handleBackdropClick = ( + event: React.MouseEvent, + ) => { + if (event.target === event.currentTarget) { + onClose(); + } + }; + + return ( + + + + ); +} diff --git a/frontend/src/pages/SearchResults/SearchEmptyState.tsx b/frontend/src/pages/SearchResults/SearchEmptyState.tsx new file mode 100644 index 0000000..bf5510d --- /dev/null +++ b/frontend/src/pages/SearchResults/SearchEmptyState.tsx @@ -0,0 +1,144 @@ + +import type { FilterCount } from "./searchTypes"; + +type SearchEmptyStateProps = { + searchTerm: string; + hasActiveFilters: boolean; + selectedStores: string[]; + alternativeStores: FilterCount[]; + onSelectStore: (storeCode: string) => void; + onResetFilters: () => void; +}; + +const storeNames: Record = { + KIWI: "Kiwi", + MENY_NO: "Meny", + SPAR_NO: "Spar", + BUNNPRIS: "Bunnpris", + COOP_NO: "Coop", + JOKER_NO: "Joker", + ODA_NO: "Oda", + EUROPRIS_NO: "Europris", + ENGROSSNETT_NO: "Engrosnett", + HAVARISTEN: "Havaristen", +}; + +export default function SearchEmptyState({ + searchTerm, + hasActiveFilters, + selectedStores, + alternativeStores, + onSelectStore, + onResetFilters, +}: SearchEmptyStateProps) { + const selectedStoreNames = selectedStores.map( + (code) => storeNames[code] ?? code, + ); + + const storeDescription = + selectedStoreNames.length === 1 + ? ` hos ${selectedStoreNames[0]}` + : selectedStoreNames.length > 1 + ? ` hos de ${selectedStoreNames.length} valgte butikkene` + : ""; + + const visibleAlternatives = alternativeStores + .filter( + (store) => + !selectedStores.includes(store.value) && + store.count > 0, + ) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + + return ( +
+ + +

+ INGEN TREFF +

+ +

+ Ingen produkter funnet +

+ +

+ Vi fant ingen produkter for «{searchTerm}» + {storeDescription} med de valgte filtrene. +

+ + {visibleAlternatives.length > 0 && ( +
+

+ Prøv en annen butikkjede +

+ +

+ Søket gir treff hos disse butikkene: +

+ +
+ {visibleAlternatives.map((store) => ( + + ))} +
+
+ )} + + {hasActiveFilters && ( + + )} + + {!hasActiveFilters && ( +

+ Prøv et annet søkeord eller en kortere + søkefrase. +

+ )} +
+ ); +} diff --git a/frontend/src/pages/SearchResults/SearchFilters.tsx b/frontend/src/pages/SearchResults/SearchFilters.tsx new file mode 100644 index 0000000..f7eb772 --- /dev/null +++ b/frontend/src/pages/SearchResults/SearchFilters.tsx @@ -0,0 +1,134 @@ + +import type { FilterCount } from "./searchTypes"; + +type SearchFiltersProps = { + categories: FilterCount[]; + stores: FilterCount[]; + selectedCategories: string[]; + onCategoriesChange: (values: string[]) => void; + selectedStores: string[]; + onStoresChange: (values: string[]) => void; +}; + +const storeNames: Record = { + KIWI: "Kiwi", + MENY_NO: "Meny", + SPAR_NO: "Spar", + BUNNPRIS: "Bunnpris", + COOP_NO: "Coop", + JOKER_NO: "Joker", + ODA_NO: "Oda", + EUROPRIS_NO: "Europris", + ENGROSSNETT_NO: "Engrosnett", +}; + +const collator = new Intl.Collator("nb-NO", { + sensitivity: "base", +}); + +export default function SearchFilters({ + categories, + stores, + selectedCategories, + onCategoriesChange, + selectedStores, + onStoresChange, +}: SearchFiltersProps) { + const sortedCategories = [...categories].sort((a, b) => { + if (a.label === "Ukategorisert") return 1; + if (b.label === "Ukategorisert") return -1; + return collator.compare(a.label, b.label); + }); + + const sortedStores = [...stores].sort((a, b) => + collator.compare( + storeNames[a.value] ?? a.label, + storeNames[b.value] ?? b.label, + ), + ); + + const toggle = ( + current: string[], + value: string, + checked: boolean, + onChange: (values: string[]) => void, + ) => { + onChange( + checked + ? Array.from(new Set([...current, value])) + : current.filter((item) => item !== value), + ); + }; + + return ( +
+
+ + KATEGORI + + + {sortedCategories.map((category) => ( + + ))} +
+ +
+ + BUTIKKJEDE + + + {sortedStores.map((store) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/pages/SearchResults/SearchInput.tsx b/frontend/src/pages/SearchResults/SearchInput.tsx new file mode 100644 index 0000000..2fa680a --- /dev/null +++ b/frontend/src/pages/SearchResults/SearchInput.tsx @@ -0,0 +1,39 @@ + +import { useEffect, useState } from "react"; +import "../../components/searchfield/Searchfield.css"; + +type SearchInputProps = { + value: string; + onSearchChange: (value: string) => void; +}; + +export default function SearchInput({ + value, + onSearchChange, +}: SearchInputProps) { + const [inputValue, setInputValue] = useState(value); + + useEffect(() => { + const timeout = window.setTimeout(() => { + onSearchChange(inputValue.trim()); + }, 350); + + return () => window.clearTimeout(timeout); + }, [inputValue, onSearchChange]); + + return ( +
+
+ ); +} + diff --git a/frontend/src/pages/SearchResults/SearchPagination.tsx b/frontend/src/pages/SearchResults/SearchPagination.tsx new file mode 100644 index 0000000..c28b03c --- /dev/null +++ b/frontend/src/pages/SearchResults/SearchPagination.tsx @@ -0,0 +1,76 @@ + +type SearchPaginationProps = { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +}; + +export default function SearchPagination({ + currentPage, + totalPages, + onPageChange, +}: SearchPaginationProps) { + if (totalPages <= 1) { + return null; + } + + const pages = Array.from( + new Set([ + 1, + 2, + currentPage - 1, + currentPage, + currentPage + 1, + totalPages, + ]), + ) + .filter((page) => page >= 1 && page <= totalPages) + .sort((a, b) => a - b); + + return ( + + ); +} diff --git a/frontend/src/pages/SearchResults/SearchProductList.tsx b/frontend/src/pages/SearchResults/SearchProductList.tsx new file mode 100644 index 0000000..5814869 --- /dev/null +++ b/frontend/src/pages/SearchResults/SearchProductList.tsx @@ -0,0 +1,131 @@ + +import type { + SearchProduct, + ProductPrice, +} from "./searchTypes"; + +type SearchProductListProps = { + products: SearchProduct[]; + selectedStores?: string[]; + startIndex?: number; + onAddProduct?: (product: SearchProduct) => void; +}; + +const formatter = new Intl.NumberFormat("nb-NO", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +const storeNames: Record = { + KIWI: "Kiwi", + MENY_NO: "Meny", + SPAR_NO: "Spar", + BUNNPRIS: "Bunnpris", + COOP_NO: "Coop", + JOKER_NO: "Joker", + ODA_NO: "Oda", + EUROPRIS_NO: "Europris", + ENGROSSNETT_NO: "Engrosnett", +}; + +function getDisplayPrice( + prices: ProductPrice[], + selectedStores: string[], +): ProductPrice | null { + const availablePrices = + selectedStores.length === 0 + ? prices + : prices.filter((price) => + selectedStores.includes(price.storeCode), + ); + + if (availablePrices.length === 0) { + return null; + } + + return availablePrices.reduce((lowest, current) => + current.price < lowest.price ? current : lowest, + ); +} + +export default function SearchProductList({ + products, + selectedStores = [], + startIndex = 0, + onAddProduct, +}: SearchProductListProps) { + return ( +
+ + +
    + {products.map((product, index) => { + const displayPrice = getDisplayPrice( + product.prices, + selectedStores, + ); + + return ( +
  1. + + {String( + startIndex + index + 1, + ).padStart(3, "0")} + + +
    + {product.name} + {product.brand && ( + + {product.brand} + + )} +
    + + + {displayPrice?.unitPrice != null + ? formatter.format(displayPrice.unitPrice) + : "—"} + + + + {displayPrice + ? formatter.format(displayPrice.price) + : "Pris mangler"} + + {displayPrice && ( + + {storeNames[displayPrice.storeCode] ?? + displayPrice.storeCode} + + )} + + + +
  2. + ); + })} +
+
+ ); +} diff --git a/frontend/src/pages/SearchResults/SearchResultsPage.css b/frontend/src/pages/SearchResults/SearchResultsPage.css new file mode 100644 index 0000000..cbb78df --- /dev/null +++ b/frontend/src/pages/SearchResults/SearchResultsPage.css @@ -0,0 +1,1322 @@ +/* ========================================================================== + HANDLELAPPEN – SØKERESULTATER + Issue #37 + + Farger og fonter hentes fra theme.css (CSS-variabler som --bg-page, + --text-primary, --font-ui osv.). Fungerer i både light og dark mode. + + OVERSIKT OVER FILEN + -------------------------------------------------------------------------- + DEL 1: DESKTOP (standard-styling som gjelder alle skjermstørrelser) + 1. Grunnstruktur og side + 2. Overskrift (tittel, seksjonsetikett, antall treff) + 3. Hovedlayout (filter | resultater | handleliste) + 4. Søkefelt på søkeresultatsiden + 5. Verktøylinje (toolbar) + 6. Sortering – enkel (.search-sort-select) + 6b. Egendefinert nedtrekksmeny (.search-sort-dropdown) + Begge deler beholderen .search-sort og etiketten .search-sort-label. + ========================================================================== */ + +/* ---- 6a. Felles: beholder og etikett ---- */ + +/* Beholder som legger etikett og nedtrekk på én linje */ +.search-sort { + display: flex; + align-items: center; + gap: 10px; +} + +/* Liten etikett («Sorter»), monospace med store bokstaver */ +.search-sort-label { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); +} + +/* ---- 6b. Enkel + + + + +

+ 12 400 VARER · 6 KJEDER · OPPDATERT DAGLIG +

+ + + {/* ====================================== + AKTIV HANDLELAPP + ====================================== */} + + + + + {/* ====================================== + KATEGORIER – FIGMA-KORT + ====================================== */} + +
+
+
+

+ + 01 + + Kategorier +

+ +

+ {categories.length} AVDELINGER · BLA I KATALOGEN → +

+
+ + {categoriesError && ( +

+ Kunne ikke hente produktantall. + Du kan fortsatt velge en kategori. +

+ )} + + {categoriesLoading && ( +

Henter kategorier...

+ )} + + {!categoriesLoading && !categoriesError && categories.length === 0 && ( +

Ingen kategorier tilgjengelige akkurat nå.

+ )} + +
    + {categories.map((category, index) => ( +
  • + +
  • + ))} +
+
+
+ + {/* ====================================== + BUNNTEKST + ====================================== */} + +
+

+ HANDLELAPPEN — IT2810 · GRUPPE 27 +

+ +

SIDE 01 / 11

+
+ + ); +} + +export default HomePage; diff --git a/frontend/src/pages/home/categoryNavigation.ts b/frontend/src/pages/home/categoryNavigation.ts new file mode 100644 index 0000000..7212433 --- /dev/null +++ b/frontend/src/pages/home/categoryNavigation.ts @@ -0,0 +1,13 @@ +export function createCategoryUrl( + categoryIds: string[], +): string { + const params = new URLSearchParams(); + + params.set("q", ""); + + for (const categoryId of categoryIds) { + params.append("category", categoryId); + } + + return `/search?${params.toString()}`; +} diff --git a/frontend/src/pages/home/homeCategories.ts b/frontend/src/pages/home/homeCategories.ts new file mode 100644 index 0000000..0bd7ec8 --- /dev/null +++ b/frontend/src/pages/home/homeCategories.ts @@ -0,0 +1,40 @@ + +export type HomeCategoryDefinition = { + name: string; + description: string; +}; + +export const homeCategories: HomeCategoryDefinition[] = [ + { + name: "Frukt & grønt", + description: "Frukt, grønnsaker, urter", + }, + { + name: "Meieri & egg", + description: "Melk, yoghurt, egg", + }, + { + name: "Ost", + description: "Hvitost, brunost, smøreost", + }, + { + name: "Bakeri", + description: "Brød, knekkebrød, bakervarer", + }, + { + name: "Kjøtt", + description: "Kjøttdeig, storfe, svin", + }, + { + name: "Pålegg & frokost", + description: "Pålegg, frokostblandinger", + }, + { + name: "Middag", + description: "Middagsretter og tilbehør", + }, + { + name: "Drikke", + description: "Kaffe, te, juice", + }, +]; diff --git a/frontend/src/pages/productDetails/ProductDetailsPage.css b/frontend/src/pages/productDetails/ProductDetailsPage.css new file mode 100644 index 0000000..3c9a3ce --- /dev/null +++ b/frontend/src/pages/productDetails/ProductDetailsPage.css @@ -0,0 +1,534 @@ + +/* ===== PRODUKTDETALJSIDE ===== */ + +.product-details-page { + width: 100%; + max-width: 1100px; + margin: 0 auto; + padding: 48px 24px 80px; + box-sizing: border-box; + background-color: var(--bg-surface); + color: var(--text-primary); + text-align: left; +} + +.product-details-page *, +.product-details-page *::before, +.product-details-page *::after { + box-sizing: border-box; +} + +.product-details-page h1, +.product-details-page h2, +.product-details-page p, +.product-details-page figure, +.product-details-page ol, +.product-details-page dl { + margin: 0; +} + +.product-details-page h1, +.product-details-page h2 { + color: var(--text-primary); + text-align: left; +} + +/* ===== TILBAKE-LENKE ===== */ + +.product-details-page .product-back-navigation { + margin-bottom: 26px; +} + +.product-details-page .product-back-navigation a { + color: var(--text-accent); + text-decoration: none; + font-size: 11px; + font-weight: 600; + letter-spacing: 1px; +} + +.product-details-page .product-back-navigation a:hover { + text-decoration: underline; +} + +/* ===== HOVEDLAYOUT ===== */ + +.product-details-page .product-details-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 52px; + align-items: start; +} + +.product-details-page .product-details-left, +.product-details-page .product-details-right { + min-width: 0; + text-align: left; +} + +.product-details-page .product-details-left { + display: flex; + flex-direction: column; + gap: 30px; +} + +/* ===== PRODUKTBILDE ===== */ + +.product-details-page .product-figure { + margin: 0; +} + +.product-details-page .product-image { + width: 100%; + aspect-ratio: 1.2 / 1; + border-radius: 10px; + + display: flex; + justify-content: center; + align-items: center; +} + +.product-details-page .product-image p { + color: var(--text-muted); + font-size: 12px; + letter-spacing: 1px; +} + +.product-details-page .product-figure figcaption { + margin-top: 12px; + color: var(--text-muted); + font-size: 10px; + letter-spacing: 1px; +} + +/* Produkt med bilde */ +.product-details-page .product-image.has-image { + background: transparent; + border: none; +} + +/* Når produktet mangler bilde */ +.product-details-page .product-image.no-image { + 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: 20px 20px; + border: 1px solid var(--border-brand); +} + +.product-details-page .product-image-content { + width: 100%; + height: 100%; + object-fit: contain; +} + +/* ===== INGREDIENSER OG ALLERGENER ===== */ + +.product-details-page .product-ingredients h2, +.product-details-page .product-allergens h2, +.product-details-page .product-nutrition h2 { + padding-bottom: 12px; + margin-bottom: 14px; + border-bottom: 1px solid var(--border-brand); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; +} + +.product-details-page .section-letter { + color: var(--text-accent); + margin-right: 5px; +} + +.product-details-page .product-ingredients p { + font-size: 14px; + line-height: 1.7; + color: var(--text-secondary); +} + +.product-details-page .product-allergens p { + display: inline-block; + padding: 7px 10px; + border-radius: 4px; + background: var(--bg-tint); + color: var(--text-primary); + font-size: 11px; + font-weight: 600; +} + +/* ===== PRODUKTINFORMASJON ===== */ + +.product-details-page .product-info { + margin-bottom: 28px; +} + +.product-details-page .product-brand { + color: var(--text-accent); + font-size: 11px; + font-weight: 600; + letter-spacing: 1px; + margin-bottom: 8px; +} + +.product-details-page .product-name { + color: var(--text-primary); + font-size: 42px; + font-weight: 600; + line-height: 1.12; + letter-spacing: -1px; +} + +.product-details-page .product-category { + margin-top: 10px; + color: var(--text-muted); + font-size: 11px; + letter-spacing: 1px; +} + +/* Prisområde */ + +.product-details-page .product-price-container { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 16px; + margin-top: 30px; + margin-bottom: 28px; +} + +.product-details-page .product-price { + display: inline-flex; + align-items: baseline; + gap: 5px; + margin: 0; + white-space: nowrap; + line-height: 1; +} + +.product-details-page .price-number { + font-size: 42px; + font-weight: 500; + color: var(--text-primary); +} + +.product-details-page .price-currency { + font-family: "Brush Script MT", "Apple Chancery", cursive; + font-size: 60px; /* Større */ + font-weight: 300; /* Tynnere */ + font-style: italic; + color: var(--text-accent); + line-height: 1; +} + +.product-details-page .product-cheapest-store { + margin: 8px 0 0; + color: var(--text-muted); + font-family: monospace; + font-size: 10px; + letter-spacing: 0.5px; +} + +/* ===== PRISSAMMENLIGNING ===== */ + +.product-details-page .store-prices { + border: 1px solid var(--border-brand); + border-radius: 10px; + overflow: hidden; + box-shadow: 0 3px 0 rgba(120, 11, 43, 0.12); +} + +.product-details-page .store-prices-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; +} + +.product-details-page .store-prices-header h2 { + margin: 0; + padding: 0; + border: none; + font-size: 11px; + font-weight: 600; + letter-spacing: 1px; +} + +.product-details-page .store-prices-header span { + color: var(--text-muted); + font-size: 10px; + white-space: nowrap; +} + +.product-details-page .store-prices-list { + margin: 0; + padding: 0; + list-style: none; +} + +.product-details-page .store-price-row { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) 88px 90px; + align-items: center; + gap: 8px; + min-height: 46px; + padding: 12px 16px; + border-top: 1px solid var(--border-brand); +} + +.product-details-page .store-price-row.cheapest { + background: var(--bg-tint); +} + +.product-details-page .store-number { + color: var(--text-accent); + font-size: 10px; + font-weight: 700; +} + +.product-details-page .store-name { + color: var(--text-primary); + font-size: 13px; + font-weight: 500; +} + +.product-details-page .store-difference { + color: var(--text-muted); + font-size: 10px; + text-align: right; + white-space: nowrap; +} + +.product-details-page .cheapest .store-difference { + justify-self: end; + padding: 5px 7px; + border-radius: 3px; + background: var(--text-accent); + color: white; + font-size: 9px; + font-weight: 700; +} + +.product-details-page .store-price { + color: var(--text-primary); + font-size: 13px; + font-weight: 600; + text-align: right; + white-space: nowrap; +} + +/* ===== HANDLELISTE ===== */ +.product-details-page .shopping-list-controls { + position: relative; + display: grid; + grid-template-columns: auto 1fr; + column-gap: 12px; + align-items: center; + + margin-top: 38px; + padding: 26px 26px 24px; + + border: 1px solid var(--text-primary); + border-left: 3px solid var(--text-accent); + background: var(--bg-surface); + box-shadow: 0 12px 18px rgba(0, 0, 0, 0.08); +} + +.product-details-page .shopping-list-controls::before { + content: ""; + position: absolute; + width: 68px; + height: 16px; + top: -9px; + left: 50%; + transform: translateX(-50%) rotate(2deg); + background: var(--note-tape); +} + +.product-details-page .shopping-list-header { + grid-column: 1 / -1; +} + + +.product-details-page .shopping-list-header h2 { + color: var(--text-accent); + font-style: italic; + font-weight: 600; + line-height: 1.3; + font-size: 21px; +} + +.product-details-page .quantity-controls { + display: inline-flex; + align-items: center; + border: 1px solid var(--border-hairline); + border-radius: 5px; + overflow: hidden; + grid-column: 1; + margin-top: 12px; +} + +.product-details-page .quantity-controls button { + min-width: 32px; + min-height: 32px; + padding: 4px; + background: var(--bg-surface); + border: none; + color: var(--text-primary); + cursor: pointer; +} + +.product-details-page .quantity-controls button:hover:not(:disabled) { + background: var(--bg-tint); +} + +.product-details-page .quantity-controls button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.product-details-page .quantity-controls output { + min-width: 24px; + text-align: center; + font-size: 12px; + color: var(--text-primary); +} + +.product-details-page .remove-product { + grid-column: 2; + justify-self: start; + + margin: 12px 0 0; + padding: 9px 12px; + + border: 1px solid var(--border-brand); + border-radius: 5px; + background: var(--bg-surface); + color: var(--text-primary); + + font-size: 11px; + cursor: pointer; +} + +.product-details-page .remove-product:hover { + background: var(--bg-tint); +} + +.product-details-page .shopping-list-controls label { + display: block; + margin-top: 24px; + margin-bottom: 10px; + color: var(--text-muted); + font-size: 10px; + letter-spacing: 1px; +} + +.product-details-page .shopping-list-controls textarea { + display: block; + width: 100%; + padding: 8px 0; + border: none; + border-bottom: 1px solid #f3b4cc; + border-radius: 0; + background: transparent; + color: var(--text-primary); + font: inherit; + font-size: 14px; + resize: vertical; + min-height: 40px; + height: 45px; +} + +.product-details-page .shopping-list-controls textarea::placeholder { + color: var(--text-muted); + font-style: italic; +} + +.product-details-page .shopping-list-controls label, +.product-details-page .shopping-list-controls textarea { + grid-column: 1 / -1; +} + +.product-details-page button:focus-visible, +.product-details-page textarea:focus-visible, +.product-details-page a:focus-visible { + outline: 2px solid #780b2b; + outline-offset: 3px; +} + +/* ===== NÆRINGSINNHOLD ===== */ + +.product-details-page .product-nutrition { + margin-top: 38px; +} + +.product-details-page .product-nutrition dl { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + column-gap: 16px; + row-gap: 10px; + margin: 0; + font-size: 12px; +} + +.product-details-page .product-nutrition dt { + color: var(--text-secondary); +} + +.product-details-page .product-nutrition dd { + margin: 0; + color: var(--text-primary); + font-weight: 600; + text-align: right; +} + +.product-details-page .nutrition-subitem { + padding-left: 12px; + color: #999 !important; +} + +/* ===== RESPONSIVT DESIGN ===== */ + +@media (max-width: 768px) { + .product-details-page { + padding: 28px 16px 60px; + } + + .product-details-page .product-details-layout { + grid-template-columns: 1fr; + gap: 32px; + } + + .product-details-page .product-name { + font-size: 34px; + } + + .product-details-page .product-price { + font-size: 32px; + } +} + +@media (max-width: 480px) { + .product-details-page .store-price-row { + grid-template-columns: 20px minmax(0, 1fr) auto; + gap: 6px; + padding: 12px; + } + + .product-details-page .store-difference { + grid-column: 2; + grid-row: 2; + text-align: left; + justify-self: start; + } + + .product-details-page .store-price { + grid-column: 3; + grid-row: 1 / 3; + } + + .product-details-page .shopping-list-controls { + padding: 20px 16px; + } +} diff --git a/frontend/src/pages/productDetails/ProductDetailsPage.tsx b/frontend/src/pages/productDetails/ProductDetailsPage.tsx new file mode 100644 index 0000000..6723a18 --- /dev/null +++ b/frontend/src/pages/productDetails/ProductDetailsPage.tsx @@ -0,0 +1,245 @@ +import { useState } from "react"; +import { useQuery } from "@apollo/client/react"; +import { PRODUCT_QUERY } from "./productQueries"; +import "./ProductDetailsPage.css"; + +type ProductDetailsPageProps = { + ean: string; +}; + +const storeNames: Record = { + KIWI: "Kiwi", + MENY_NO: "Meny", + SPAR_NO: "Spar", + BUNNPRIS: "Bunnpris", + COOP_NO: "Coop", + JOKER_NO: "Joker", + ODA_NO: "Oda", + EUROPRIS_NO: "Europris", + ENGROSSNETT_NO: "Engrosnett", +}; + +const getStoreName = (storeCode: string) => + storeNames[storeCode] ?? storeCode; + +export default function ProductDetailsPage({ + ean, +}: ProductDetailsPageProps) { + const { data, loading, error } = useQuery(PRODUCT_QUERY, { + variables: { ean }, + }); + + const product = data?.product; + const [quantity, setQuantity] = useState(1); + const [isInList, setIsInList] = useState(false); + const [note, setNote] = useState(""); + const [failedImageUrl, setFailedImageUrl] = useState(null); + + if (loading) { + return
Laster produkt...
; + } + + if (error) { + return ( +
+

Kunne ikke hente produktet: {error.message}

+
+ ); + } + + if (!product) { + return ( +
+

Fant ikke produktet.

+
+ ); + } + + const storePrices = [...product.prices].sort( + (a, b) => a.price - b.price + ); + + const cheapest = storePrices[0]; + + const formatPrice = (price: number) => + price.toFixed(2).replace(".", ","); + + const hasImage = Boolean( + product.imageUrl && product.imageUrl !== failedImageUrl + ); + + return ( +
+
+ + {/* Venstre side: Produktbilde */} +
+
+
+ {hasImage ? ( + {product.name} setFailedImageUrl(product.imageUrl)} + /> + ) : ( +

Mangler bilde

+ )} +
+ +
+ FIG. 5 — {product.name.toUpperCase()} +
+
+ +
+

A — INGREDIENSER

+

Ingrediensinformasjon er ikke tilgjengelig.

+
+ +
+

B — ALLERGENER

+

Allergeninformasjon er ikke tilgjengelig.

+
+ + +
+ + {/* Høyre side: Produktinformasjon */} +
+ {product.brand && ( +

{product.brand}

+ )} + +

{product.name}

+ + {product.category && ( +

{product.category.name}

+ )} + +
+ {cheapest ? ( + <> +

+ + {formatPrice(cheapest.price)} + + kr +

+ +

+ BILLIGST HOS {getStoreName(cheapest.storeCode).toUpperCase()} +

+ + ) : ( +

Ingen priser tilgjengelig.

+ )} +
+
+
+

PRIS I BUTIKKENE

+ {storePrices.length} KJEDER +
+ +
    + {storePrices.map((item, index) => { + const difference = item.price - (cheapest?.price ?? item.price); + + return ( +
  1. + + {String(index + 1).padStart(2, "0")} + + + + {getStoreName(item.storeCode)} + + + + {index === 0 + ? "BILLIGST" + : `+${formatPrice(difference)} KR`} + + + + {item.price.toFixed(2).replace(".", ",")} kr + +
  2. + ); + })} +
+
+
+ +
+

+ {isInList ? "Står på lappen din" : "Ikke på lappen din"} +

+
+
+ + + {isInList ? quantity : 0} + + +
+ + + + {isInList && ( + + )} + + + +