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
-
-
- Søk etter varer
- 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 && (
-
- {data.products.map((product) => {
- const price = lowestPrice(product.prices);
- return (
-
- {product.imageUrl ? (
-
- ) : (
-
- )}
-
-
{product.name}
- {product.brand &&
{product.brand}
}
-
- {price !== null
- ? `Fra ${price.toFixed(2)} kr`
- : "Pris mangler"}
-
-
-
- );
- })}
-
- )}
-
+
+
+ }
+ />
+
+
+ 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 (
+
+ );
+}
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) => (
+
+ onSelectStore(store.value)
+ }
+ >
+
+ {storeNames[store.value] ??
+ store.label}
+
+
+
+ {store.count} treff
+
+
+ →
+
+ ))}
+
+
+ )}
+
+ {hasActiveFilters && (
+
+ Nullstill alle filtre
+
+ )}
+
+ {!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) => (
+
+
+
+ toggle(
+ selectedCategories,
+ category.value,
+ event.target.checked,
+ onCategoriesChange,
+ )
+ }
+ />
+ {category.label}
+
+
+ {category.count}
+
+
+ ))}
+
+
+
+
+ BUTIKKJEDE
+
+
+ {sortedStores.map((store) => (
+
+
+
+ toggle(
+ selectedStores,
+ store.value,
+ event.target.checked,
+ onStoresChange,
+ )
+ }
+ />
+
+ {storeNames[store.value] ?? store.label}
+
+
+
+ {store.count}
+
+
+ ))}
+
+
+ );
+}
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 (
+
+
+
+ setInputValue(event.target.value)}
+ placeholder="Søk etter matvarer..."
+ aria-label="Søk etter matvarer"
+ />
+
+ );
+}
+
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 (
+
+ onPageChange(currentPage - 1)}
+ aria-label="Forrige side"
+ >
+ ←
+
+
+ {pages.map((page, index) => (
+
+ {index > 0 && page > pages[index - 1] + 1 && (
+ …
+ )}
+
+ onPageChange(page)}
+ >
+ {page}
+
+
+ ))}
+
+ onPageChange(currentPage + 1)}
+ aria-label="Neste side"
+ >
+ →
+
+
+ );
+}
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 (
+
+
+ #
+ Vare
+ Kr/kg
+ Pris
+
+
+
+
+ {products.map((product, index) => {
+ const displayPrice = getDisplayPrice(
+ product.prices,
+ selectedStores,
+ );
+
+ return (
+
+
+ {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}
+
+ )}
+
+
+ onAddProduct?.(product)}
+ disabled={!onAddProduct}
+ aria-label={`Legg ${product.name} på handlelisten`}
+ >
+ + Legg på lappen
+
+
+ );
+ })}
+
+
+ );
+}
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 og egendefinert nedtrekksmeny
+ 7. Aktive filtre (chips)
+ 8. Filterpanel (venstre kolonne)
+ 9. Produktliste (tabell, rader, pris, butikk, legg til-knapp)
+ 10. Paginering
+ 11. Tomt søkeresultat
+ 12. Midlertidig handlelisteområde (høyre kolonne)
+
+ DEL 2: NETTBRETT OG MOBIL (alle @media-regler samlet)
+ 13. Nettbrett (opptil 1100px og 900px)
+ 14. Mobil (opptil 700px)
+ 15. Liten mobil (opptil 600px)
+ 16. Mobil – nye tilpasninger (tom seksjon, klar til å fylles ut)
+
+ Merk: Alle @media-regler ligger nederst med vilje. De skal overstyre
+ desktop-stylingen, og da må de komme etter den i filen.
+ ========================================================================== */
+
+
+/* ==========================================================================
+ DEL 1: DESKTOP – STANDARD-STYLING
+ ========================================================================== */
+
+
+/* ==========================================================================
+ 1. GRUNNSTRUKTUR OG SIDE
+ Felles regler for hele søkeresultatsiden.
+ ========================================================================== */
+
+/* Gjør at padding og border regnes med i bredden på alle elementer på siden */
+.search-results-page,
+.search-results-page * {
+ box-sizing: border-box;
+}
+
+/* Selve siden: sentrert container med maks bredde, bakgrunn og tekstfarge */
+.search-results-page {
+ width: 100%;
+ max-width: 1440px;
+ margin: 0 auto;
+ padding: 48px 40px 80px;
+ background: var(--bg-page);
+ color: var(--text-primary);
+ font-family: var(--font-ui);
+ text-align: left;
+}
+
+/* Felles utseende for overskrifter (h1–h3) på siden */
+.search-results-page h1,
+.search-results-page h2,
+.search-results-page h3 {
+ margin-top: 0;
+ color: var(--text-primary);
+ font-family: var(--font-ui);
+}
+
+/* Lange ord uten mellomrom (for eksempel et langt søkeord) brytes
+ i stedet for å gå utenfor skjermen. Gjelder alle skjermstørrelser. */
+.search-results-page h1,
+.search-results-page h2,
+.search-results-page h3,
+.search-results-page p,
+.search-empty-state {
+ overflow-wrap: anywhere;
+}
+
+
+/* ==========================================================================
+ 2. OVERSKRIFT
+ Toppen av siden: liten seksjonsetikett, stor tittel og antall treff.
+ ========================================================================== */
+
+/* Beholder for hele overskriften, med strek under */
+.search-results-header {
+ padding-bottom: 32px;
+ margin-bottom: 32px;
+ border-bottom: 1px solid var(--border-hairline);
+}
+
+/* Liten etikett over tittelen (monospace, store bokstaver) */
+.search-results-section-label {
+ margin: 0 0 12px;
+ color: var(--text-accent);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 500;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+/* Hovedtittel. Størrelsen skalerer mellom 32px og 48px etter skjermbredde */
+.search-results-header h1 {
+ margin: 0 0 12px;
+ font-size: clamp(32px, 4vw, 48px);
+ font-weight: 500;
+ letter-spacing: -0.04em;
+ line-height: 1.15;
+}
+
+/* Uthevet ord i tittelen (kursiv, merkefarge, egen font) */
+.search-results-header h1 em {
+ color: var(--text-brand);
+ font-family: var(--font-accent);
+ font-style: italic;
+ font-weight: 400;
+}
+
+/* Tekst som viser antall treff under tittelen */
+.search-results-count {
+ margin: 0;
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+
+/* ==========================================================================
+ 3. HOVEDLAYOUT
+ Tre kolonner på desktop: filter (200px) | resultater (fleksibel) | liste (300px).
+ ========================================================================== */
+
+/* Grid som plasserer de tre kolonnene ved siden av hverandre */
+.search-results-layout {
+ display: grid;
+ grid-template-columns: 200px minmax(0, 1fr) 300px;
+ gap: 32px;
+ align-items: start;
+}
+
+/* min-width: 0 hindrer at innhold presser kolonnene bredere enn grid-en */
+.search-results-filters,
+.search-results-content,
+.search-results-shopping-list {
+ min-width: 0;
+}
+
+/* Minimumshøyde på resultatlisten, så siden ikke hopper når listen er kort */
+.search-results-list {
+ min-height: 300px;
+}
+
+
+/* ==========================================================================
+ 4. SØKEFELT PÅ SØKERESULTATSIDEN
+ Sentrert søkefelt over hovedlayouten.
+ ========================================================================== */
+
+/* Ytre beholder som sentrerer søkefeltet */
+.search-results-searchbar {
+ display: flex;
+ justify-content: center;
+ margin-bottom: 36px;
+}
+
+/* Selve søkefeltet: full bredde opp til 640px */
+.search-results-searchbar .search-field {
+ width: 100%;
+ max-width: 640px;
+}
+
+/* min-width: 0 lar søkefeltet krympe i stedet for å presse siden bredere */
+.search-results-searchbar,
+.search-results-searchbar .search-field {
+ min-width: 0;
+}
+
+
+/* ==========================================================================
+ 5. VERKTØYLINJE (TOOLBAR)
+ Raden over produktlisten med tittel til venstre og sortering til høyre.
+ ========================================================================== */
+
+/* Raden med strek under */
+.search-results-toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+ margin-bottom: 20px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--border-hairline);
+}
+
+/* Tittelen i verktøylinjen */
+.search-results-toolbar h2 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+
+/* ==========================================================================
+ 6. SORTERING
+ To varianter ligger i filen:
+ 6a. 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 med egen pil (tegnet med to gradienter) ---- */
+
+.search-sort-select {
+ appearance: none;
+ -webkit-appearance: none;
+
+ width: 190px;
+ padding: 10px 38px 10px 14px;
+
+ border: 1px solid var(--border-hairline);
+ border-radius: 6px;
+
+ background-color: var(--bg-surface);
+ background-image:
+ linear-gradient(
+ 45deg,
+ transparent 50%,
+ var(--text-primary) 50%
+ ),
+ linear-gradient(
+ 135deg,
+ var(--text-primary) 50%,
+ transparent 50%
+ );
+
+ background-position:
+ calc(100% - 21px) 50%,
+ calc(100% - 16px) 50%;
+
+ background-size: 5px 5px;
+ background-repeat: no-repeat;
+
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ cursor: pointer;
+}
+
+.search-sort-select:hover {
+ border-color: var(--border-brand);
+}
+
+/* Synlig fokus for tastaturbrukere (tilgjengelighet) */
+.search-sort-select:focus-visible {
+ outline: 2px solid var(--text-accent);
+ outline-offset: 3px;
+}
+
+/* ---- 6c. Egendefinert sorteringsmeny: ytre beholder ---- */
+
+/* position: relative gjør at menyen kan plasseres rett under knappen */
+.search-sort-dropdown {
+ position: relative;
+ width: 190px;
+ flex-shrink: 0;
+}
+
+/* ---- 6d. Egendefinert sorteringsmeny: knappen som åpner menyen ---- */
+
+.search-sort-trigger {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+
+ width: 100%;
+ min-height: 42px;
+ padding: 10px 18px 10px 14px;
+
+ border: 1px solid var(--border-hairline);
+ border-radius: 7px;
+
+ background: var(--bg-surface);
+ color: var(--text-primary);
+
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 500;
+ text-align: left;
+
+ cursor: pointer;
+}
+
+.search-sort-trigger:hover {
+ border-color: var(--border-brand);
+}
+
+.search-sort-trigger:focus-visible {
+ outline: 2px solid var(--text-accent);
+ outline-offset: 3px;
+}
+
+/* ---- 6e. Egendefinert sorteringsmeny: pilen (chevron) i knappen ---- */
+
+/* Pilen lages av to kanter på en firkant som roteres 45 grader */
+.search-sort-chevron {
+ width: 8px;
+ height: 8px;
+ flex-shrink: 0;
+
+ border-right: 1.5px solid currentColor;
+ border-bottom: 1.5px solid currentColor;
+
+ transform: translateY(-2px) rotate(45deg);
+ transition: transform 0.15s ease;
+}
+
+/* Pilen snur seg når menyen er åpen */
+.search-sort-chevron--open {
+ transform: translateY(2px) rotate(225deg);
+}
+
+/* ---- 6f. Egendefinert sorteringsmeny: selve menyen ---- */
+
+/* Menyen åpnes rett under knappen og ligger over annet innhold (z-index) */
+.search-sort-menu {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ z-index: 100;
+
+ width: 100%;
+ padding: 5px;
+
+ border: 1px solid var(--border-hairline);
+ border-radius: 8px;
+
+ background: var(--bg-surface);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
+}
+
+/* ---- 6g. Egendefinert sorteringsmeny: alternativene i menyen ---- */
+
+.search-sort-option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+
+ width: 100%;
+ padding: 11px 10px;
+
+ border: none;
+ border-radius: 5px;
+
+ background: transparent;
+ color: var(--text-primary);
+
+ font-family: var(--font-mono);
+ font-size: 11px;
+ text-align: left;
+
+ cursor: pointer;
+}
+
+/* Hover og tastaturfokus får samme utseende */
+.search-sort-option:hover,
+.search-sort-option:focus-visible {
+ background: var(--bg-tint);
+ color: var(--text-brand);
+ outline: none;
+}
+
+/* Alternativet som er valgt nå */
+.search-sort-option--selected {
+ color: var(--text-accent);
+ font-weight: 600;
+}
+
+/* Hake ved valgt alternativ */
+.search-sort-check {
+ color: var(--text-accent);
+ font-size: 14px;
+ font-weight: 600;
+}
+
+
+/* ==========================================================================
+ 7. AKTIVE FILTRE (CHIPS)
+ Små knapper som viser hvilke filtre som er valgt, og en «nullstill»-knapp.
+ ========================================================================== */
+
+/* Raden som holder alle chips */
+.search-active-filters {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 18px;
+}
+
+/* Én chip (et valgt filter som kan klikkes for å fjernes) */
+.search-filter-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 12px;
+ border: 1px solid transparent;
+ border-radius: 5px;
+ background: var(--bg-tint);
+ color: var(--text-brand);
+ font: 500 11px var(--font-mono);
+ cursor: pointer;
+}
+
+.search-filter-chip:hover {
+ border-color: var(--border-brand);
+}
+
+/* Tekstknapp for å nullstille alle filtre */
+.search-filter-reset {
+ border: 0;
+ padding: 8px;
+ background: transparent;
+ color: var(--text-accent);
+ font: 500 11px var(--font-mono);
+ cursor: pointer;
+}
+
+/* Synlig fokus for tastaturbrukere (gjelder både chip og nullstill) */
+.search-filter-chip:focus-visible,
+.search-filter-reset:focus-visible {
+ outline: 2px solid var(--text-accent);
+ outline-offset: 3px;
+}
+
+
+/* ==========================================================================
+ 8. FILTERPANEL (VENSTRE KOLONNE)
+ Grupper med avkrysningsbokser, for eksempel kategori og butikk.
+ ========================================================================== */
+
+/* Beholder som stabler alle filtergruppene vertikalt */
+.search-filters {
+ display: flex;
+ flex-direction: column;
+ gap: 30px;
+ text-align: left;
+}
+
+/* Én gruppe (en ). Nullstiller nettleserens standardstil */
+.search-filter-group {
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+/* Gruppeoverskrift (monospace) med strek under */
+.search-filter-heading {
+ width: 100%;
+ padding-bottom: 10px;
+ margin-bottom: 12px;
+ border-bottom: 1px solid var(--border-hairline);
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 500;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+/* Nummer eller symbol foran overskriften */
+.search-filter-heading span {
+ margin-right: 6px;
+ color: var(--text-accent);
+}
+
+/* ---- Ett filteralternativ: navn til venstre, antall til høyre ---- */
+
+.search-filter-option {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ padding: 7px 0;
+ color: var(--text-primary);
+ font-size: 13px;
+ cursor: pointer;
+}
+
+/* Boks og navn ved siden av hverandre */
+.search-filter-option-name {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+}
+
+/* Antall treff for alternativet */
+.search-filter-count {
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+}
+
+.search-filter-option:hover {
+ color: var(--text-accent);
+}
+
+/* ---- Avkrysningsboksen ----
+ Samme visuelle prinsipp som Lindes Checkbox.
+ Standard utseende fjernes (appearance: none) og tegnes på nytt. */
+
+.search-filter-option input {
+ 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;
+}
+
+/* Valgt: fylt bakgrunn */
+.search-filter-option input:checked {
+ background: var(--bg-brand);
+ border-color: var(--bg-brand);
+}
+
+/* Valgt: haken tegnes med to kanter på et lite element som roteres */
+.search-filter-option input: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);
+}
+
+/* Synlig fokus for tastaturbrukere */
+.search-filter-option input:focus-visible {
+ outline: 2px solid var(--text-accent);
+ outline-offset: 2px;
+}
+
+/* Knappen som åpner filtrene på mobil. Skjult på desktop,
+ vises igjen i seksjon 14 (mobil). */
+.search-mobile-filter-toggle {
+ display: none;
+}
+
+
+/* ==========================================================================
+ 9. PRODUKTLISTE
+ Tabell med kolonner: nummer | produkt | enhetspris | pris | legg til-knapp.
+ ========================================================================== */
+
+/* ---- 9a. Tabellen og kolonneoppsettet ---- */
+
+.search-product-table {
+ width: 100%;
+}
+
+/* Overskriftsraden og hver produktrad deler samme kolonner:
+ 32px nummer | fleksibel produktinfo | 80px enhetspris | 90px pris | 145px knapp */
+.search-product-table-header,
+.search-product-row {
+ display: grid;
+ grid-template-columns:
+ 32px minmax(0, 1fr) 80px 90px 145px;
+ column-gap: 12px;
+ align-items: center;
+}
+
+/* Overskriftsraden over listen */
+.search-product-table-header {
+ padding: 12px 0;
+ border-bottom: 1px solid var(--border-strong);
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+/* Listen med produktrader (nullstiller standard listestil) */
+.search-product-items {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+/* ---- 9b. Én produktrad ---- */
+
+.search-product-row {
+ min-height: 70px;
+ padding: 12px 0;
+ border-bottom: 1px solid var(--border-hairline);
+}
+
+/* Radnummer */
+.search-product-number {
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ font-size: 11px;
+}
+
+/* Navn og merke stablet vertikalt */
+.search-product-info {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+}
+
+/* Produktnavn */
+.search-product-info strong {
+ color: var(--text-primary);
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1.3;
+}
+
+/* Merkenavn under produktnavnet */
+.search-product-brand {
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ font-size: 10px;
+ text-transform: uppercase;
+}
+
+/* ---- 9c. Pris, enhetspris og butikk ---- */
+
+/* Enhetspris (for eksempel kr/kg) */
+.search-product-unit-price {
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ font-size: 11px;
+}
+
+/* Hovedpris */
+.search-product-price {
+ color: var(--text-primary);
+ font-size: 15px;
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+}
+
+/* Prisen og butikknavnet stablet vertikalt i prisfeltet */
+.search-product-price-column {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 3px;
+ min-width: 0;
+}
+
+/* Butikknavn i prisfeltet */
+.search-product-store-name {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-accent);
+}
+
+/* Butikketikett under pris */
+.search-product-store {
+ display: block;
+ margin-top: 4px;
+ color: var(--text-accent);
+ font: 500 10px var(--font-mono);
+ text-transform: uppercase;
+}
+
+/* ---- 9d. «Legg til»-knappen ---- */
+
+.search-product-add-button {
+ padding: 10px 8px;
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ border: 1px solid var(--border-hairline);
+ border-radius: 7px;
+ font: 500 12px var(--font-ui);
+ cursor: pointer;
+}
+
+.search-product-add-button:hover:not(:disabled) {
+ background: var(--bg-tint);
+ border-color: var(--border-brand);
+}
+
+/* Deaktivert tilstand (for eksempel mens varen legges til) */
+.search-product-add-button:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+.search-product-add-button:focus-visible {
+ outline: 2px solid var(--text-accent);
+ outline-offset: 3px;
+}
+
+
+/* ==========================================================================
+ 10. PAGINERING
+ «Viser X av N»-tekst og sideknapper under produktlisten.
+ ========================================================================== */
+
+/* Tekst som viser hvor mange varer som vises */
+.search-pagination-count {
+ margin: 24px 0 12px;
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ letter-spacing: 0.04em;
+}
+
+/* Raden med sideknapper (brytes til flere linjer ved behov) */
+.search-pagination {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 16px;
+}
+
+/* Hvert element i pagineringen (knapp eller «...») */
+.search-pagination-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* Sideknapper */
+.search-pagination button {
+ min-width: 36px;
+ height: 36px;
+ padding: 0 10px;
+ border: 1px solid var(--border-hairline);
+ border-radius: 7px;
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ font: 500 12px var(--font-mono);
+ cursor: pointer;
+}
+
+.search-pagination button:hover:not(:disabled) {
+ background: var(--bg-tint);
+ border-color: var(--border-brand);
+}
+
+/* Knappen for siden brukeren er på nå */
+.search-pagination button.is-active {
+ background: var(--bg-brand);
+ color: var(--text-on-dark);
+ border-color: var(--bg-brand);
+}
+
+.search-pagination button:focus-visible {
+ outline: 2px solid var(--text-accent);
+ outline-offset: 3px;
+}
+
+/* Deaktivert knapp (for eksempel «forrige» på første side) */
+.search-pagination button:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+}
+
+/* Tekst mellom knappene (for eksempel «...») */
+.search-pagination-item > span {
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+}
+
+
+/* ==========================================================================
+ 11. TOMT SØKERESULTAT
+ Vises når søk eller filtre ikke gir treff.
+ ========================================================================== */
+
+/* ---- 11a. Enkel tomtilstand ---- */
+
+.search-no-results {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 24px 0;
+ color: var(--text-primary);
+}
+
+/* ---- 11b. Utvidet tomtilstand: boks, ikon og tekster ---- */
+
+/* Selve boksen */
+.search-empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 14px;
+ padding: 48px 24px;
+ border: 1px solid var(--border-hairline);
+ border-radius: 10px;
+ background: var(--bg-surface);
+ color: var(--text-primary);
+}
+
+/* Rund ikonsirkel øverst */
+.search-empty-state-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 64px;
+ height: 64px;
+ margin-bottom: 6px;
+ border-radius: 50%;
+ background: var(--bg-tint);
+ color: var(--text-accent);
+}
+
+/* Liten etikett over tittelen */
+.search-empty-state-label {
+ font: 500 10px var(--font-mono);
+ letter-spacing: 0.12em;
+ color: var(--text-accent);
+}
+
+/* Tittel. Størrelsen skalerer mellom 22px og 30px */
+.search-empty-state-title {
+ margin: 0;
+ font-family: var(--font-ui);
+ font-size: clamp(22px, 3vw, 30px);
+ font-weight: 500;
+ letter-spacing: -0.03em;
+}
+
+/* Beskrivelse og hjelpetekst */
+.search-empty-state-description,
+.search-empty-state-hint {
+ max-width: 420px;
+ color: var(--text-secondary);
+ font-size: 13px;
+ line-height: 1.6;
+}
+
+/* ---- 11c. Utvidet tomtilstand: alternativer i andre butikker ---- */
+
+/* Område med strek over som foreslår andre butikker */
+.search-empty-state-alternatives {
+ width: 100%;
+ margin-top: 14px;
+ padding-top: 20px;
+ border-top: 1px solid var(--border-hairline);
+}
+
+.search-empty-state-alternatives h4 {
+ margin: 0 0 8px;
+ font-size: 15px;
+ font-weight: 600;
+}
+
+.search-empty-state-alternatives > p {
+ margin-bottom: 16px;
+ color: var(--text-muted);
+ font-size: 12px;
+}
+
+/* Liste med butikkene */
+.search-empty-state-store-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+/* Én butikk som knapp: navn til venstre, antall til høyre */
+.search-empty-state-store {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ width: 100%;
+ padding: 13px 16px;
+ border: 1px solid var(--border-hairline);
+ border-radius: 7px;
+ background: var(--bg-page);
+ color: var(--text-primary);
+ font: 500 13px var(--font-ui);
+ text-align: left;
+ cursor: pointer;
+}
+
+.search-empty-state-store:hover {
+ border-color: var(--border-brand);
+ background: var(--bg-tint);
+}
+
+/* Antall treff i butikken (skyves helt til høyre) */
+.search-empty-state-store-count {
+ margin-left: auto;
+ color: var(--text-muted);
+ font: 500 10px var(--font-mono);
+}
+
+/* ---- 11d. Utvidet tomtilstand: nullstill-knappen ---- */
+
+.search-empty-state-reset {
+ margin-top: 12px;
+ padding: 11px 16px;
+ border: 1px solid var(--border-brand);
+ border-radius: 7px;
+ background: var(--bg-surface);
+ color: var(--text-brand);
+ font: 500 12px var(--font-ui);
+ cursor: pointer;
+}
+
+.search-empty-state-reset:hover {
+ background: var(--bg-tint);
+}
+
+/* Synlig fokus for tastaturbrukere (butikkknapp og nullstill-knapp) */
+.search-empty-state-store:focus-visible,
+.search-empty-state-reset:focus-visible {
+ outline: 2px solid var(--text-accent);
+ outline-offset: 3px;
+}
+
+
+/* ==========================================================================
+ 12. MIDLERTIDIG HANDLELISTEOMRÅDE (HØYRE KOLONNE)
+ Plassholder som erstattes senere med Lindes komponent.
+ ========================================================================== */
+
+/* Boks i «lapp»-stil med skygge forskjøvet nedover og til høyre */
+.search-results-shopping-list {
+ padding: 24px;
+ background: var(--note-paper);
+ color: var(--note-ink);
+ border: 1px solid var(--border-strong);
+ box-shadow: 6px 6px 0 var(--note-tint);
+}
+
+.search-results-shopping-list h2 {
+ margin: 0 0 12px;
+ color: var(--note-ink);
+ font-size: 16px;
+}
+
+.search-results-shopping-list p {
+ color: var(--text-secondary);
+}
+
+
+/* ==========================================================================
+ DEL 2: NETTBRETT OG MOBIL – ALLE @MEDIA-REGLER
+ --------------------------------------------------------------------------
+ Alt under gjelder BARE når skjermen er smal. Bredde, høyde, padding, antall
+ kolonner og skjulte elementer i denne delen er tilpasninger for nettbrett
+ og mobil. Desktop-stylingen ligger i DEL 1 over.
+
+ Rekkefølgen er fra bredest til smalest (1100 → 900 → 700 → 600), slik at
+ de smaleste reglene vinner når flere gjelder samtidig.
+ ========================================================================== */
+
+
+/* ==========================================================================
+ 13. NETTBRETT (OPPTIL 1100px OG 900px)
+ ========================================================================== */
+
+/* ---- 13a. Opptil 1100px: hovedlayout går fra 3 til 2 kolonner ---- */
+@media (max-width: 1100px) {
+ /* Filter (180px) og resultater. Handlelisten flyttes under */
+ .search-results-layout {
+ grid-template-columns: 180px minmax(0, 1fr);
+ }
+
+ /* Handlelisten strekker seg over alle kolonnene, under resultatene */
+ .search-results-shopping-list {
+ grid-column: 1 / -1;
+ }
+}
+
+/* ---- 13b. Opptil 900px: produkttabellen forenkles ---- */
+@media (max-width: 900px) {
+ /* Tabellen får 3 kolonner: nummer | produkt | pris */
+ .search-product-table-header,
+ .search-product-row {
+ grid-template-columns: 28px minmax(0, 1fr) 80px;
+ gap: 10px;
+ }
+
+ /* Skjuler kolonneoverskrift 3 og 5 samt enhetsprisen for å spare plass */
+ .search-product-table-header span:nth-child(3),
+ .search-product-table-header span:nth-child(5),
+ .search-product-unit-price {
+ display: none;
+ }
+
+ /* Legg til-knappen flyttes til egen linje under produktinfoen */
+ .search-product-add-button {
+ grid-column: 2 / -1;
+ justify-self: start;
+ }
+}
+
+
+/* ==========================================================================
+ 14. MOBIL (OPPTIL 700px)
+ Én kolonne, mindre marger og tilpasninger for søkefelt og aktive filtre.
+ ========================================================================== */
+
+/* ---- 14a. Side og hovedlayout ---- */
+@media (max-width: 700px) {
+ /* Mindre padding rundt hele siden */
+ .search-results-page {
+ padding: 24px 16px 48px;
+ }
+
+ /* Alt stables i én kolonne med mindre mellomrom */
+ .search-results-layout {
+ grid-template-columns: 1fr;
+ gap: 24px;
+ }
+
+ /* Mindre luft under overskriften */
+ .search-results-header {
+ margin-bottom: 24px;
+ }
+
+ /* Filter og handleliste legges i den ene kolonnen */
+ .search-results-filters,
+ .search-results-shopping-list {
+ grid-column: 1;
+ }
+}
+
+/* ---- 14b. Søkefelt ---- */
+@media (max-width: 700px) {
+ /* Mindre luft under søkefeltet */
+ .search-results-searchbar {
+ margin-bottom: 24px;
+ }
+}
+
+/* ---- 14c. Aktive filtre (chips) ---- */
+@media (max-width: 700px) {
+ /* Tettere mellom chips */
+ .search-active-filters {
+ gap: 6px;
+ }
+
+ /* Lange chips får bryte til flere linjer i stedet for å sprenge bredden */
+ .search-filter-chip {
+ max-width: 100%;
+ white-space: normal;
+ text-align: left;
+ }
+}
+
+/* ---- 14d. Filterknapp og skjult filterpanel på mobil ---- */
+@media (max-width: 700px) {
+ /* Knappen vises og får samme stil som sorteringsknappen */
+ .search-mobile-filter-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ min-height: 44px;
+ padding: 10px 14px;
+ border: 1px solid var(--border-hairline);
+ border-radius: 7px;
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ font: 500 12px var(--font-mono);
+ text-align: left;
+ cursor: pointer;
+ }
+
+ .search-mobile-filter-toggle:hover {
+ border-color: var(--border-brand);
+ }
+
+ /* Synlig fokus for tastaturbrukere */
+ .search-mobile-filter-toggle:focus-visible {
+ outline: 2px solid var(--text-accent);
+ outline-offset: 3px;
+ }
+
+ /* Filterpanelet er skjult til brukeren trykker på knappen */
+ .search-mobile-filter-panel {
+ display: none;
+ margin-top: 16px;
+ }
+
+ /* Åpent: React legger klassen is-open på */
+ .search-results-filters.is-open .search-mobile-filter-panel {
+ display: block;
+ }
+}
+
+
+/* ==========================================================================
+ 15. LITEN MOBIL (OPPTIL 600px)
+ Verktøylinje, sortering og tomtilstand.
+ ========================================================================== */
+
+/* ---- 15a. Verktøylinje og enkel sortering ---- */
+@media (max-width: 600px) {
+ /* Elementene i verktøylinjen får bryte til ny linje */
+ .search-results-toolbar {
+ flex-wrap: wrap;
+ gap: 12px;
+ }
+
+ /* Sorteringen tar full bredde */
+ .search-sort {
+ width: 100%;
+ }
+
+ /* Nedtrekket vokser og fyller bredden som er igjen */
+ .search-sort-select {
+ flex: 1;
+ min-width: 0;
+ }
+}
+
+/* ---- 15b. Egendefinert sorteringsmeny ---- */
+@media (max-width: 600px) {
+ /* Menyen tar full bredde i stedet for faste 190px */
+ .search-sort-dropdown {
+ width: 100%;
+ flex: 1;
+ min-width: 0;
+ }
+
+ .search-sort-trigger {
+ width: 100%;
+ }
+
+ .search-sort-menu {
+ width: 100%;
+ }
+}
+
+/* ---- 15c. Tomt søkeresultat ---- */
+@media (max-width: 600px) {
+ /* Mindre padding i boksen */
+ .search-empty-state {
+ padding: 28px 16px;
+ }
+
+ /* Butikkens navn og antall får bryte til ny linje */
+ .search-empty-state-store {
+ flex-wrap: wrap;
+ }
+}
+
+
+/* ==========================================================================
+ 16. MOBIL – NYE TILPASNINGER (KLAR TIL Å FYLLES UT)
+ --------------------------------------------------------------------------
+ Seksjonen er med vilje tom. Her legger vi alt nytt som gjelder mobilvisning:
+ bredder, høyder, padding, skriftstørrelser, skjulte elementer osv.
+ Alt i denne seksjonen skal ligge inne i en @media-regel.
+
+ Mal for nye regler (fjern kommentartegnene når du bruker den):
+
+ @media (max-width: 480px) {
+ /* Eksempel: .search-product-add-button { width: 100%; } *\/
+ }
+
+ Tips:
+ - Ligger nye regler her nederst, vinner de over reglene i seksjon 13–15
+ når de gjelder samme bredde.
+ - Legg til en kort kommentar over hver regel som forklarer hva den gjør.
+ ========================================================================== */
+
+ /* ---- 16-0. Hindrer sidelengs scrolling på mobil ---- */
+ @media (max-width: 700px) {
+ /* minmax(0, 1fr) lar kolonnen være smalere enn innholdet.
+ Vanlig 1fr vokser etter det bredeste ordet og sprenger skjermen. */
+ .search-results-layout {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ /* Sikkerhetsnett: klipper alt som likevel stikker ut til siden */
+ .search-results-page {
+ overflow-x: clip;
+ }
+ }
+
+ /* ---- 16a. Mobil (opptil 700px): større trykkflater og scrollbart filterpanel ---- */
+ @media (max-width: 700px) {
+ /* Åpent filterpanel får maks høyde og scroller internt,
+ slik at ikke hele siden skyves langt ned */
+ .search-mobile-filter-panel {
+ max-height: 60vh;
+ overflow-y: auto;
+ overscroll-behavior: contain; /* hindrer at siden scroller når panelet når enden */
+ }
+
+ /* Filteralternativer: høyere rader, lettere å treffe med tommel */
+ .search-filter-option {
+ padding: 12px 0;
+ }
+
+ /* Avkrysningsboks: større på mobil (haken justeres tilsvarende) */
+ .search-filter-option input {
+ width: 20px;
+ height: 20px;
+ }
+
+ .search-filter-option input:checked::after {
+ width: 10px;
+ height: 5px;
+ }
+
+ /* Sideknapper i pagineringen: 40px i stedet for 36px */
+ .search-pagination button {
+ min-width: 40px;
+ height: 40px;
+ }
+
+ /* Søkefeltet: 16px skrift hindrer at iPhone zoomer inn ved fokus */
+ .search-results-searchbar input {
+ font-size: 16px;
+ }
+}
+
+/* ---- 16b. Liten mobil (opptil 480px, dekker 390px, 360px og 320px) ---- */
+@media (max-width: 480px) {
+ /* Mindre padding på siden, så innholdet får mer plass */
+ .search-results-page {
+ padding: 20px 12px 40px;
+ }
+
+ /* Tettere overskrift */
+ .search-results-header {
+ padding-bottom: 20px;
+ margin-bottom: 20px;
+ }
+
+ /* Mindre tittel, og lange søkeord får brytes i stedet for å sprenge bredden */
+ .search-results-header h1 {
+ font-size: 28px;
+ overflow-wrap: anywhere;
+ }
+
+ /* Tabell: smalere nummer-kolonne og fast bredde på prisen */
+ .search-product-table-header,
+ .search-product-row {
+ grid-template-columns: 24px minmax(0, 1fr) 76px;
+ column-gap: 8px;
+ }
+
+ /* Lange produktnavn brytes i stedet for å gå utenfor skjermen */
+ .search-product-info strong,
+ .search-product-brand {
+ overflow-wrap: anywhere;
+ }
+
+ /* «Legg på lappen» går over hele bredden og er minst 44px høy */
+ .search-product-add-button {
+ justify-self: stretch;
+ min-height: 44px;
+ }
+
+ /* Tomtilstand: mindre padding og mindre ikon */
+ .search-empty-state {
+ padding: 24px 14px;
+ }
+
+ .search-empty-state-icon {
+ width: 48px;
+ height: 48px;
+ }
+
+ /* Handlelisteboksen: mindre padding */
+ .search-results-shopping-list {
+ padding: 16px;
+ }
+}
+/* =========================================
+ PAGINERING – SCROLLPOSISJON
+ ========================================= */
+
+.search-results-content {
+ scroll-margin-top: 80px;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/SearchResults/SearchResultsPage.tsx b/frontend/src/pages/SearchResults/SearchResultsPage.tsx
new file mode 100644
index 0000000..ed2c727
--- /dev/null
+++ b/frontend/src/pages/SearchResults/SearchResultsPage.tsx
@@ -0,0 +1,665 @@
+import {
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+
+import { gql } from "@apollo/client";
+import type { TypedDocumentNode } from "@apollo/client";
+
+import { useQuery } from "@apollo/client/react";
+
+import SearchFilters from "./SearchFilters";
+import SearchPagination from "./SearchPagination";
+import SearchProductList from "./SearchProductList";
+import SearchSortSelect from "./SearchSortSelect";
+
+import {
+ SEARCH_PRODUCTS_QUERY,
+ SEARCH_FILTER_COUNTS_QUERY,
+} from "./searchQueries";
+
+import {
+ loadSearchPreferences,
+ saveSearchPreferences,
+} from "./searchPreferences";
+
+import type {
+ SearchSort,
+ SearchProduct,
+ FilterCount,
+} from "./searchTypes";
+
+import "./SearchResultsPage.css";
+
+import SearchEmptyState from "./SearchEmptyState";
+
+import { useSearchParams } from "react-router";
+
+const PAGE_SIZE = 20;
+
+// Typer for spørringen som henter alle kategorinavn
+type CategoryNamesData = {
+ categories: { id: string; name: string }[];
+};
+
+// Henter alle kategorinavn én gang, slik at filterbrikkene alltid kan vise
+// navn (også etter refresh og når søket gir null treff)
+const CATEGORY_NAMES_QUERY: TypedDocumentNode = gql`
+ query CategoryNames {
+ categories {
+ id
+ name
+ }
+ }
+`;
+
+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",
+ HOLDBART: "Holdbart",
+ HAVARISTEN: "Havaristen",
+ FUDI: "Fudi",
+ PERREROBERT: "Perrerobert",
+};
+
+const collator = new Intl.Collator("nb-NO", {
+ sensitivity: "base",
+});
+
+function normalizeCounts(
+ items:
+ | ReadonlyArray<{
+ value?: string;
+ label?: string;
+ count?: number;
+ } | null>
+ | null
+ | undefined,
+): FilterCount[] {
+ return (items ?? []).flatMap((item) => {
+ if (
+ item &&
+ typeof item.value === "string" &&
+ typeof item.label === "string" &&
+ typeof item.count === "number"
+ ) {
+ return [{
+ value: item.value,
+ label: item.label,
+ count: item.count,
+ }];
+ }
+
+ return [];
+ });
+}
+
+function normalizeProducts(
+ items: ReadonlyArray<{
+ ean?: string;
+ name?: string;
+ brand?: string | null;
+ imageUrl?: string | null;
+ categoryId?: string | null;
+ prices?: ReadonlyArray<{
+ storeCode?: string;
+ price?: number;
+ unitPrice?: number | null;
+ } | null>;
+ } | null> | null | undefined,
+): SearchProduct[] {
+ return (items ?? []).flatMap((product) => {
+ if (
+ !product ||
+ typeof product.ean !== "string" ||
+ typeof product.name !== "string"
+ ) {
+ return [];
+ }
+
+ const prices = (product.prices ?? []).flatMap(
+ (price) => {
+ if (
+ !price ||
+ typeof price.storeCode !== "string" ||
+ typeof price.price !== "number"
+ ) {
+ return [];
+ }
+
+ return [{
+ storeCode: price.storeCode,
+ price: price.price,
+ unitPrice:
+ typeof price.unitPrice === "number"
+ ? price.unitPrice
+ : null,
+ }];
+ },
+ );
+
+ return [{
+ ean: product.ean,
+ name: product.name,
+ brand: product.brand ?? null,
+ imageUrl: product.imageUrl ?? null,
+ categoryId: product.categoryId ?? null,
+ prices,
+ }];
+ });
+}
+
+export default function SearchResultsPage() {
+ const [searchParams] = useSearchParams();
+ const urlSearchTerm = searchParams.get("q");
+
+ // Hent lagrede innstillinger kun ved første rendering.
+ const [initialPreferences] = useState(
+ loadSearchPreferences,
+ );
+
+ const searchTerm = urlSearchTerm ?? "";
+
+ const [currentPage, setCurrentPage] = useState(() =>
+ initialPreferences.searchTerm === searchTerm
+ ? initialPreferences.currentPage
+ : 1,
+ );
+
+ const [sort, setSort] = useState(
+ initialPreferences.sort,
+ );
+
+ const [selectedCategories, setSelectedCategories] =
+ useState(() => {
+ const urlCategories = searchParams.getAll("category");
+
+ if (urlCategories.length > 0) {
+ return urlCategories;
+ }
+
+ if (urlSearchTerm === null) {
+ return [];
+ }
+
+ return initialPreferences.selectedCategories;
+ });
+
+ const [selectedStores, setSelectedStores] =
+ useState(() =>
+ urlSearchTerm === null
+ ? []
+ : initialPreferences.selectedStores,
+ );
+
+ const [knownCategoryNames, setKnownCategoryNames] =
+ useState>({});
+
+ const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
+
+ // Brukes til å bevare scrollposisjonen når filtre endres.
+ const scrollPositionRef = useRef(null);
+ const resultsRef = useRef(null);
+ const productsSectionRef = useRef(null);
+
+ const [loadingMinHeight, setLoadingMinHeight] =
+ useState(null);
+
+ const preservePosition = () => {
+ scrollPositionRef.current = window.scrollY;
+
+ if (resultsRef.current) {
+ setLoadingMinHeight(
+ resultsRef.current.getBoundingClientRect().height,
+ );
+ }
+ };
+
+ // Gjenopprett scrollposisjon etter at React har
+ // oppdatert innholdet.
+ useLayoutEffect(() => {
+ if (scrollPositionRef.current !== null) {
+ const previousPosition = scrollPositionRef.current;
+
+ window.scrollTo({
+ top: previousPosition,
+ behavior: "instant",
+ });
+
+ scrollPositionRef.current = null;
+ }
+ });
+
+ // Lagre søkeinnstillingene automatisk.
+ useEffect(() => {
+ saveSearchPreferences({
+ searchTerm,
+ selectedCategories,
+ selectedStores,
+ sort,
+ currentPage,
+ });
+ }, [
+ searchTerm,
+ selectedCategories,
+ selectedStores,
+ sort,
+ currentPage,
+ ]);
+
+ const handleCategoriesChange = (
+ values: string[],
+ ) => {
+ preservePosition();
+ setSelectedCategories(values);
+ setCurrentPage(1);
+ };
+
+ const handleStoresChange = (
+ values: string[],
+ ) => {
+ preservePosition();
+ setSelectedStores(values);
+ setCurrentPage(1);
+ };
+
+ const handleSortChange = (value: SearchSort) => {
+ preservePosition();
+ setSort(value);
+ setCurrentPage(1);
+ };
+
+ const handlePageChange = (page: number) => {
+ if (page === currentPage) return;
+
+ setCurrentPage(page);
+
+ productsSectionRef.current?.scrollIntoView({
+ behavior: "smooth",
+ block: "start",
+ });
+ };
+
+ const resetFilters = () => {
+ preservePosition();
+ setSelectedCategories([]);
+ setSelectedStores([]);
+ setCurrentPage(1);
+ };
+
+ const hasSearch = searchTerm.trim().length > 0;
+
+ const hasCategorySelection =
+ selectedCategories.length > 0;
+
+ // Katalogen skal også kunne vise alle produkter.
+ const shouldFetchProducts = true;
+
+ // Hent produkter.
+ const {
+ data,
+ loading,
+ error,
+ } = useQuery(SEARCH_PRODUCTS_QUERY, {
+ variables: {
+ search: searchTerm.trim(),
+ categories: selectedCategories,
+ storeCodes: selectedStores,
+ sort,
+ limit: PAGE_SIZE,
+ offset: (currentPage - 1) * PAGE_SIZE,
+ },
+ skip: !shouldFetchProducts,
+ });
+
+ // Hent dynamiske filtertall.
+ const {
+ data: filterData,
+ previousData: previousFilterData,
+ loading: filtersLoading,
+ error: filtersError,
+ } = useQuery(SEARCH_FILTER_COUNTS_QUERY, {
+ variables: {
+ search: searchTerm.trim(),
+ categories: selectedCategories,
+ storeCodes: selectedStores,
+ },
+ skip: !shouldFetchProducts,
+ });
+
+ // Mens nye filtertall lastes, viser vi de forrige. Da beholder filterlisten
+ // samme høyde, og siden hopper ikke til toppen når du velger et filter.
+ const filterSource = filterData ?? previousFilterData;
+
+ const categoryCounts = normalizeCounts(
+ filterSource?.categoryFilterCounts,
+ );
+
+ const storeCounts = normalizeCounts(
+ filterSource?.storeFilterCounts,
+ );
+
+ // Henter alle kategorinavn én gang (brukes når navnet ikke finnes i filtertallene)
+ const { data: categoryNameData } = useQuery(CATEGORY_NAMES_QUERY);
+
+ // Gjør listen om til et oppslag: kategori-id -> navn
+ const allCategoryNames: Record = Object.fromEntries(
+ (categoryNameData?.categories ?? []).map((category) => [
+ category.id,
+ category.name,
+ ]),
+ );
+
+ // Prøver først filtertallene, så navn vi har sett tidligere, så alle kategorinavn.
+ // «Kategori X» vises bare hvis navnet ikke finnes noe sted.
+ const getCategoryName = (value: string) =>
+ categoryCounts.find(
+ (category) => category.value === value,
+ )?.label ??
+ knownCategoryNames[value] ??
+ allCategoryNames[value] ??
+ `Kategori ${value}`;
+
+ const updateCategories = (values: string[]) => {
+ const names = Object.fromEntries(
+ categoryCounts.map(
+ (category) => [
+ category.value,
+ category.label,
+ ],
+ ),
+ );
+
+ setKnownCategoryNames((previous) => ({
+ ...previous,
+ ...names,
+ }));
+
+ handleCategoriesChange(values);
+ };
+
+ const products = normalizeProducts(data?.products);
+
+ const totalProducts = data?.productsCount ?? 0;
+ const totalPages = Math.ceil(
+ totalProducts / PAGE_SIZE,
+ );
+
+ const firstProduct =
+ (currentPage - 1) * PAGE_SIZE + 1;
+
+ const lastProduct = Math.min(
+ currentPage * PAGE_SIZE,
+ totalProducts,
+ );
+
+ const hasActiveFilters =
+ selectedCategories.length > 0 ||
+ selectedStores.length > 0;
+
+ const sortedSelectedCategories = [
+ ...selectedCategories,
+ ].sort((a, b) =>
+ collator.compare(
+ getCategoryName(a),
+ getCategoryName(b),
+ ),
+ );
+
+ const sortedSelectedStores = [
+ ...selectedStores,
+ ].sort((a, b) =>
+ collator.compare(
+ storeNames[a] ?? a,
+ storeNames[b] ?? b,
+ ),
+ );
+
+ const storeHeading =
+ selectedStores.length === 1
+ ? ` hos ${
+ storeNames[selectedStores[0]] ??
+ selectedStores[0]
+ }`
+ : selectedStores.length > 1
+ ? ` hos ${selectedStores.length} butikker`
+ : "";
+
+ return (
+
+
+
+
+
+
+
+
+
+
Produkter
+
+
+
+
+ {hasActiveFilters && (
+
+ {sortedSelectedCategories.map(
+ (value) => (
+ {
+ preservePosition();
+
+ handleCategoriesChange(
+ selectedCategories.filter(
+ (item) => item !== value,
+ ),
+ );
+ }}
+ aria-label={`Fjern kategorifilter ${getCategoryName(value)}`}
+ >
+ Kategori: {getCategoryName(value)}
+
+ {" "}×
+
+
+ ),
+ )}
+
+ {sortedSelectedStores.map(
+ (value) => (
+ {
+ preservePosition();
+
+ handleStoresChange(
+ selectedStores.filter(
+ (item) => item !== value,
+ ),
+ );
+ }}
+ aria-label={`Fjern butikkfilter ${storeNames[value] ?? value}`}
+ >
+ Kjede: {storeNames[value] ?? value}
+
+ {" "}×
+
+
+ ),
+ )}
+
+
+ Nullstill
+
+
+ )}
+
+
+ {loading ? (
+
Laster produkter...
+ ) : error ? (
+
+ Kunne ikke hente produkter.
+ Prøv igjen senere.
+
+ ) : products.length > 0 ? (
+ <>
+
+
+
+ Viser {firstProduct}–{lastProduct} av{" "}
+ {totalProducts} produkter
+
+
+
+ >
+ ) : (
+
+
{
+ preservePosition();
+ setSelectedStores([storeCode]);
+ setCurrentPage(1);
+ }}
+ onResetFilters={resetFilters}
+ />
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/SearchResults/SearchSortSelect.tsx b/frontend/src/pages/SearchResults/SearchSortSelect.tsx
new file mode 100644
index 0000000..5422d61
--- /dev/null
+++ b/frontend/src/pages/SearchResults/SearchSortSelect.tsx
@@ -0,0 +1,212 @@
+
+import { useEffect, useId, useRef, useState } from "react";
+import type { KeyboardEvent } from "react";
+import type { SearchSort } from "./searchTypes";
+
+type SearchSortSelectProps = {
+ value: SearchSort;
+ onChange: (value: SearchSort) => void;
+};
+
+const sortOptions: { value: SearchSort; label: string }[] = [
+ { value: "relevance", label: "Relevans" },
+ { value: "price_asc", label: "Pris: lavest først" },
+ { value: "price_desc", label: "Pris: høyest først" },
+];
+
+export default function SearchSortSelect({
+ value,
+ onChange,
+}: SearchSortSelectProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [activeIndex, setActiveIndex] = useState(0);
+
+ const containerRef = useRef(null);
+ const triggerRef = useRef(null);
+ const optionRefs = useRef<(HTMLButtonElement | null)[]>([]);
+ const menuId = useId();
+
+ const selectedOption =
+ sortOptions.find((option) => option.value === value) ??
+ sortOptions[0];
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const handleOutsideClick = (event: PointerEvent) => {
+ if (
+ containerRef.current &&
+ !containerRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener("pointerdown", handleOutsideClick);
+
+ return () => {
+ document.removeEventListener(
+ "pointerdown",
+ handleOutsideClick,
+ );
+ };
+ }, [isOpen]);
+
+ const openMenu = (index: number) => {
+ setActiveIndex(index);
+ setIsOpen(true);
+
+ requestAnimationFrame(() => {
+ optionRefs.current[index]?.focus();
+ });
+ };
+
+ const closeMenu = () => {
+ setIsOpen(false);
+ triggerRef.current?.focus();
+ };
+
+ const selectOption = (selectedValue: SearchSort) => {
+ onChange(selectedValue);
+ closeMenu();
+ };
+
+ const handleTriggerKeyDown = (
+ event: KeyboardEvent,
+ ) => {
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ openMenu(0);
+ }
+
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ openMenu(sortOptions.length - 1);
+ }
+ };
+
+ const handleOptionKeyDown = (
+ event: KeyboardEvent,
+ index: number,
+ ) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ closeMenu();
+ }
+
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ const nextIndex = (index + 1) % sortOptions.length;
+ setActiveIndex(nextIndex);
+ optionRefs.current[nextIndex]?.focus();
+ }
+
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ const previousIndex =
+ (index - 1 + sortOptions.length) % sortOptions.length;
+ setActiveIndex(previousIndex);
+ optionRefs.current[previousIndex]?.focus();
+ }
+
+ if (event.key === "Home") {
+ event.preventDefault();
+ setActiveIndex(0);
+ optionRefs.current[0]?.focus();
+ }
+
+ if (event.key === "End") {
+ event.preventDefault();
+ const lastIndex = sortOptions.length - 1;
+ setActiveIndex(lastIndex);
+ optionRefs.current[lastIndex]?.focus();
+ }
+
+ if (event.key === "Tab") {
+ setIsOpen(false);
+ }
+ };
+
+ return (
+
+
+ SORTER:
+
+
+
+ {
+ if (isOpen) {
+ closeMenu();
+ } else {
+ const selectedIndex = sortOptions.findIndex(
+ (option) => option.value === value,
+ );
+ openMenu(Math.max(selectedIndex, 0));
+ }
+ }}
+ onKeyDown={handleTriggerKeyDown}
+ >
+ {selectedOption.label}
+
+
+
+
+ {isOpen && (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/SearchResults/searchPreferences.ts b/frontend/src/pages/SearchResults/searchPreferences.ts
new file mode 100644
index 0000000..11a0cae
--- /dev/null
+++ b/frontend/src/pages/SearchResults/searchPreferences.ts
@@ -0,0 +1,90 @@
+
+import type { SearchSort } from "./searchTypes";
+
+export type SearchPreferences = {
+ searchTerm: string;
+ selectedCategories: string[];
+ selectedStores: string[];
+ sort: SearchSort;
+ currentPage: number;
+};
+
+const STORAGE_KEY = "handlelappen-search-preferences";
+
+const defaultPreferences: SearchPreferences = {
+ searchTerm: "ost",
+ selectedCategories: [],
+ selectedStores: [],
+ sort: "relevance",
+ currentPage: 1,
+};
+
+export function loadSearchPreferences(): SearchPreferences {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+
+ if (!stored) {
+ return defaultPreferences;
+ }
+
+ const parsed: unknown = JSON.parse(stored);
+
+ if (!parsed || typeof parsed !== "object") {
+ return defaultPreferences;
+ }
+
+ const data = parsed as Partial;
+
+ return {
+ searchTerm:
+ typeof data.searchTerm === "string"
+ ? data.searchTerm
+ : defaultPreferences.searchTerm,
+
+ selectedCategories:
+ Array.isArray(data.selectedCategories)
+ ? data.selectedCategories.filter(
+ (value): value is string =>
+ typeof value === "string",
+ )
+ : [],
+
+ selectedStores:
+ Array.isArray(data.selectedStores)
+ ? data.selectedStores.filter(
+ (value): value is string =>
+ typeof value === "string",
+ )
+ : [],
+
+ sort:
+ data.sort === "price_asc" ||
+ data.sort === "price_desc" ||
+ data.sort === "relevance"
+ ? data.sort
+ : "relevance",
+
+ currentPage:
+ typeof data.currentPage === "number" &&
+ Number.isInteger(data.currentPage) &&
+ data.currentPage > 0
+ ? data.currentPage
+ : 1,
+ };
+ } catch {
+ return defaultPreferences;
+ }
+}
+
+export function saveSearchPreferences(
+ preferences: SearchPreferences,
+): void {
+ try {
+ localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify(preferences),
+ );
+ } catch {
+ // Lagring kan være deaktivert i nettleseren.
+ }
+}
diff --git a/frontend/src/pages/SearchResults/searchQueries.ts b/frontend/src/pages/SearchResults/searchQueries.ts
new file mode 100644
index 0000000..973f0db
--- /dev/null
+++ b/frontend/src/pages/SearchResults/searchQueries.ts
@@ -0,0 +1,79 @@
+
+import { gql } from "@apollo/client";
+import type { TypedDocumentNode } from "@apollo/client";
+
+import type {
+ SearchProductsData,
+ SearchProductsVariables,
+ SearchFilterCountsData,
+ SearchFilterCountsVariables,
+} from "./searchTypes";
+
+export const SEARCH_PRODUCTS_QUERY: TypedDocumentNode<
+ SearchProductsData,
+ SearchProductsVariables
+> = gql`
+ query SearchProducts(
+ $search: String
+ $categories: [ID!]
+ $storeCodes: [String!]
+ $sort: String
+ $limit: Int
+ $offset: Int
+ ) {
+ products(
+ search: $search
+ categories: $categories
+ storeCodes: $storeCodes
+ sort: $sort
+ limit: $limit
+ offset: $offset
+ ) {
+ ean
+ name
+ brand
+ imageUrl
+ categoryId
+ prices {
+ storeCode
+ price
+ unitPrice
+ }
+ }
+
+ productsCount(
+ search: $search
+ categories: $categories
+ storeCodes: $storeCodes
+ )
+ }
+`;
+
+export const SEARCH_FILTER_COUNTS_QUERY: TypedDocumentNode<
+ SearchFilterCountsData,
+ SearchFilterCountsVariables
+> = gql`
+ query SearchFilterCounts(
+ $search: String
+ $storeCodes: [String!]
+ $categories: [ID!]
+ ) {
+ categoryFilterCounts(
+ search: $search
+ storeCodes: $storeCodes
+ ) {
+ value
+ label
+ count
+ }
+
+ storeFilterCounts(
+ search: $search
+ categories: $categories
+ ) {
+ value
+ label
+ count
+ }
+ }
+`;
diff --git a/frontend/src/pages/SearchResults/searchTypes.ts b/frontend/src/pages/SearchResults/searchTypes.ts
new file mode 100644
index 0000000..0e44811
--- /dev/null
+++ b/frontend/src/pages/SearchResults/searchTypes.ts
@@ -0,0 +1,51 @@
+
+export type ProductPrice = {
+ storeCode: string;
+ price: number;
+ unitPrice: number | null;
+};
+
+export type SearchProduct = {
+ ean: string;
+ name: string;
+ brand: string | null;
+ imageUrl: string | null;
+ categoryId: string | null;
+ prices: ProductPrice[];
+};
+
+export type SearchSort =
+ | "relevance"
+ | "price_asc"
+ | "price_desc";
+
+export type SearchProductsData = {
+ products: SearchProduct[];
+ productsCount: number;
+};
+
+export type SearchProductsVariables = {
+ search?: string;
+ categories?: string[];
+ storeCodes?: string[];
+ sort?: SearchSort;
+ limit?: number;
+ offset?: number;
+};
+
+export type FilterCount = {
+ value: string;
+ label: string;
+ count: number;
+};
+
+export type SearchFilterCountsData = {
+ categoryFilterCounts: FilterCount[];
+ storeFilterCounts: FilterCount[];
+};
+
+export type SearchFilterCountsVariables = {
+ search?: string;
+ storeCodes?: string[];
+ categories?: string[];
+};
diff --git a/frontend/src/pages/home/HomePage.css b/frontend/src/pages/home/HomePage.css
new file mode 100644
index 0000000..4eb7e5c
--- /dev/null
+++ b/frontend/src/pages/home/HomePage.css
@@ -0,0 +1,332 @@
+.home {
+ min-height: 100vh;
+ background: var(--bg-page);
+ color: var(--text-primary);
+ font-family: var(--font-ui);
+ text-align: left;
+}
+
+/* Innholdet holdes innenfor samme bredde som i Figma (1312px) */
+.home__hero {
+ max-width: 1312px;
+ margin: 0 auto;
+ padding: 64px 48px;
+ box-sizing: border-box;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 340px;
+ gap: 64px;
+}
+
+.hero {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ max-width: 660px;
+}
+
+.hero__eyebrow {
+ margin: 0;
+ font: 500 11px var(--font-mono);
+ letter-spacing: 0.06em;
+ color: var(--text-accent);
+}
+
+.hero__title {
+ margin: 0;
+ font-size: 78px;
+ font-weight: 500;
+ line-height: 82px;
+ letter-spacing: -0.04em;
+}
+
+/* «lappen»: kursiv serif. Farge og font kommer fra theme.css (h1 em) */
+.hero__title em {
+ font-size: 92px;
+ letter-spacing: -0.01em;
+}
+
+.hero__lead {
+ max-width: 520px;
+ margin: 0 0 4px;
+ font-size: 18px;
+ line-height: 28px;
+ color: var(--text-secondary);
+}
+
+.hero__stats {
+ margin: 0;
+ font: 500 10px var(--font-mono);
+ letter-spacing: 0.06em;
+ color: var(--text-muted);
+}
+/* ---------- Den aktive lappen ---------- */
+.active-note {
+ width: 340px;
+ max-width: 100%;
+}
+
+.active-note__fig {
+ margin: 0 0 24px;
+ font: 500 10px var(--font-mono);
+ letter-spacing: 0.06em;
+ color: var(--annotation);
+}
+
+.active-note__stack {
+ position: relative;
+}
+
+/* Arket som ligger bak, litt skjevt */
+.active-note__back {
+ position: absolute;
+ inset: 30px -8px -12px 8px;
+ background: var(--note-tint);
+ border: 1.25px solid var(--border-strong);
+ border-radius: 2px;
+ transform: rotate(4deg);
+}
+
+/* Selve lappen */
+.active-note__paper {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 34px 26px 26px 46px;
+ background: var(--note-paper);
+ border: 1.25px solid var(--border-strong);
+ border-radius: 2px;
+ transform: rotate(-2deg);
+ box-shadow: 3px 16px 22px -10px rgba(64, 5, 18, 0.1);
+}
+
+/* Den røde margen til venstre */
+.active-note__paper::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 30px;
+ width: 1.25px;
+ background: var(--note-margin);
+}
+
+/* Tapen øverst */
+.active-note__tape {
+ position: absolute;
+ top: -13px;
+ left: 50%;
+ width: 78px;
+ height: 20px;
+ background: var(--note-tape);
+ transform: translateX(-50%) rotate(4deg);
+}
+
+.active-note__label,
+.active-note__meta {
+ margin: 0;
+ font: 500 10px var(--font-mono);
+ letter-spacing: 0.06em;
+}
+
+.active-note__label {
+ color: var(--text-accent);
+}
+
+.active-note__meta {
+ color: var(--text-muted);
+}
+
+.active-note__title {
+ margin: 0;
+ font: 700 34px/42px var(--font-note);
+ color: var(--note-ink);
+}
+
+.active-note__list {
+ margin: 8px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.active-note__list .note-item {
+ cursor: pointer;
+}
+
+/* Avkrysset vare blir grå og strøket ut */
+.note-item:has(.checkbox:checked) .note-item__name {
+ color: var(--text-muted);
+ text-decoration: line-through;
+}
+
+.active-note__more {
+ margin: 0;
+ font: 400 17px var(--font-note);
+ color: var(--text-muted);
+}
+
+.active-note__total {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ margin-top: 10px;
+}
+
+.active-note__sum {
+ font: 700 28px/32px var(--font-note);
+ color: var(--note-ink);
+}
+
+.active-note__button {
+ justify-content: center;
+ width: 100%;
+ margin-top: 10px;
+}
+/* ---------- Kategorier ---------- */
+.home__categories {
+ border-top: 1px solid var(--border-hairline);
+ border-bottom: 1px solid var(--border-hairline);
+}
+
+.categories {
+ max-width: 1312px;
+ margin: 0 auto;
+ padding: 32px 48px 42px;
+ box-sizing: border-box;
+}
+
+.categories__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: 16px;
+ margin-bottom: 28px;
+}
+
+.categories__title {
+ display: flex;
+ align-items: flex-end;
+ gap: 16px;
+ margin: 0;
+ font-size: 32px;
+ font-weight: 500;
+ letter-spacing: -0.03em;
+}
+
+.categories__number {
+ font: 500 12px var(--font-mono);
+ letter-spacing: 0.06em;
+ color: var(--text-accent);
+}
+
+.categories__link {
+ margin: 0;
+ font: 500 10px var(--font-mono);
+ letter-spacing: 0.06em;
+ color: var(--text-muted);
+}
+
+.categories__grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 24px 20px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.categories__grid .category-card {
+ width: 100%;
+ max-width: none;
+}
+
+/* ---------- Bunntekst ---------- */
+.home__footer {
+ display: flex;
+ justify-content: space-between;
+ max-width: 1312px;
+ margin: 0 auto;
+ padding: 16px 48px 32px;
+ box-sizing: border-box;
+ font: 500 10px var(--font-mono);
+ letter-spacing: 0.06em;
+ color: var(--text-muted);
+}
+
+.home__footer p {
+ margin: 0;
+}
+
+/* Smal skjerm: lappen legger seg under teksten */
+@media (max-width: 1000px) {
+ .home__hero {
+ grid-template-columns: 1fr;
+ }
+
+ .active-note {
+ justify-self: center;
+ }
+
+ .categories__grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+/* Mobil: mindre overskrift og luft */
+@media (max-width: 600px) {
+ .home__hero {
+ padding: 32px 16px;
+ }
+ .hero__title {
+ font-size: 44px;
+ line-height: 48px;
+ }
+ .hero__title em {
+ font-size: 52px;
+ }
+
+ .categories,
+ .home__footer {
+ padding-left: 16px;
+ padding-right: 16px;
+ }
+
+ .categories__header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .categories__grid {
+ grid-template-columns: 1fr;
+ }
+
+
+ .home__hero {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .hero {
+ min-width: 0;
+ width: 100%;
+ }
+
+ .hero__lead {
+ min-width: 0;
+ max-width: 100%;
+ overflow-wrap: break-word;
+ }
+
+ .active-note {
+ width: 100%;
+ min-width: 0;
+ }
+
+}
+
+/* Unngå at den dekorative lappen gir horisontal scrolling */
+@media (max-width: 600px) {
+ .active-note__back {
+ right: 4px;
+ transform: rotate(2deg);
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/pages/home/HomePage.tsx b/frontend/src/pages/home/HomePage.tsx
new file mode 100644
index 0000000..c1c709e
--- /dev/null
+++ b/frontend/src/pages/home/HomePage.tsx
@@ -0,0 +1,379 @@
+
+import type { FormEvent } from "react";
+
+import { gql } from "@apollo/client";
+import type { TypedDocumentNode } from "@apollo/client";
+import { useQuery } from "@apollo/client/react";
+
+import { homeCategories } from "./homeCategories";
+
+import "./HomePage.css";
+import "../../components/searchfield/Searchfield.css";
+import "../../components/button/Button.css";
+import "../../components/noteItem/NoteItem.css";
+import "../../components/checkbox/Checkbox.css";
+import "../../components/categoryCard/CategoryCard.css";
+
+// ==========================================
+// MIDLERTIDIG HANDLELAPP
+// Erstattes med ekte handlelistedata senere.
+// ==========================================
+
+const noteItems = [
+ { name: "Lettmelk", count: "×2" },
+ { name: "Norvegia 26 %" },
+ { name: "Mozzarella", count: "×2" },
+ { name: "Bananer", checked: true },
+ { name: "Tomater", count: "×4" },
+];
+
+// ==========================================
+// GRAPHQL – KATEGORIER OG PRODUKTANTALL
+// ==========================================
+
+type CategoryCount = {
+ value: string;
+ label: string;
+ count: number;
+};
+
+type CategoryCountsData = {
+ categoryFilterCounts: CategoryCount[];
+};
+
+const HOME_CATEGORY_COUNTS_QUERY: TypedDocumentNode<
+ CategoryCountsData
+> = gql`
+ query HomeCategoryCounts {
+ categoryFilterCounts {
+ value
+ label
+ count
+ }
+ }
+`;
+
+// ==========================================
+// PROPS
+// ==========================================
+
+type HomePageProps = {
+ onSearch: (value: string) => void;
+ onCategorySelect: (categoryIds: string[]) => void;
+};
+
+// ==========================================
+// HJEMMESIDE
+// ==========================================
+
+function HomePage({
+ onSearch,
+ onCategorySelect,
+}: HomePageProps) {
+ // Henter faktiske produktantall fra GraphQL.
+ const {
+ data: categoryData,
+ loading: categoriesLoading,
+ error: categoriesError,
+ } = useQuery(HOME_CATEGORY_COUNTS_QUERY);
+
+ const categoryCounts =
+ categoryData?.categoryFilterCounts ?? [];
+
+
+ const categories = homeCategories
+ .flatMap((category) => {
+ const matchingCategory = categoryCounts.find(
+ (item) =>
+ item.label.toLocaleLowerCase("nb-NO") ===
+ category.name.toLocaleLowerCase("nb-NO"),
+ );
+
+ if (!matchingCategory) {
+ return [];
+ }
+
+ return [
+ {
+ name: category.name,
+ description: category.description,
+ categoryIds: [matchingCategory.value],
+ count: matchingCategory.count,
+ },
+ ];
+ })
+ .sort((a, b) =>
+ a.name.localeCompare(b.name, "nb-NO"),
+ );
+
+ // ==========================================
+ // SØK FRA STARTSIDEN
+ // ==========================================
+
+ const handleSearchSubmit = (
+ event: FormEvent,
+ ) => {
+ event.preventDefault();
+
+ const formData = new FormData(event.currentTarget);
+ const search = String(
+ formData.get("search") ?? "",
+ ).trim();
+
+ if (search) {
+ onSearch(search);
+ }
+ };
+
+ // ==========================================
+ // RENDER
+ // ==========================================
+
+ return (
+
+ {/* ======================================
+ HERO – OVERSKRIFT OG SØKEFELT
+ ====================================== */}
+
+
+
+
+ HANDLELAPPEN · VOL. 01 · HØST 2026
+
+
+
+ Hva skal på
+ lappen i dag?
+
+
+
+ Søk etter en vare, eller bla deg gjennom en kategori.
+ Lappen holder styr på resten, og viser hva det
+ koster i hver kjede.
+
+
+
+
+
+ 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) => (
+
+
+ onCategorySelect(category.categoryIds)
+ }
+ aria-label={`Vis produkter i ${category.name}`}
+ >
+
+
+ {String(index + 1).padStart(2, "0")}
+
+
+
+ {categoriesLoading
+ ? "Laster..."
+ : categoriesError
+ ? "Antall ukjent"
+ : `${category.count.toLocaleString("nb-NO")} varer`}
+
+
+
+
+
+
+ {category.name}
+
+
+
+ {category.description}
+
+
+
+
+ →
+
+
+
+
+ ))}
+
+
+
+
+ {/* ======================================
+ BUNNTEKST
+ ====================================== */}
+
+
+
+ );
+}
+
+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 ? (
+ 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 (
+
+
+ {String(index + 1).padStart(2, "0")}
+
+
+
+ {getStoreName(item.storeCode)}
+
+
+
+ {index === 0
+ ? "BILLIGST"
+ : `+${formatPrice(difference)} KR`}
+
+
+
+ {item.price.toFixed(2).replace(".", ",")} kr
+
+
+ );
+ })}
+
+
+
+
+
+
+ {isInList ? "Står på lappen din" : "Ikke på lappen din"}
+
+
+
+ setQuantity((q) => Math.max(1, q - 1))}
+ >
+ −
+
+
+ {isInList ? quantity : 0}
+
+ {
+ setQuantity((q) => (isInList ? q + 1 : 1));
+ setIsInList(true);
+ }}
+ >
+ +
+
+
+
+
+
+ {isInList && (
+ setIsInList(false)}
+ >
+ Fjern fra lappen
+
+ )}
+
+
+ NOTAT TIL LAPPEN (VALGFRITT)
+
+
+
+
+
+ C — NÆRINGSINNHOLD
+ Næringsinnhold er ikke tilgjengelig.
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/pages/productDetails/productQueries.ts b/frontend/src/pages/productDetails/productQueries.ts
new file mode 100644
index 0000000..83fca79
--- /dev/null
+++ b/frontend/src/pages/productDetails/productQueries.ts
@@ -0,0 +1,51 @@
+import { gql } from "@apollo/client";
+import type { TypedDocumentNode } from "@apollo/client";
+
+export type Product = {
+ ean: string;
+ name: string;
+ brand: string | null;
+ imageUrl: string | null;
+ category: {
+ id: string;
+ name: string;
+ } | null;
+ prices: {
+ storeCode: string;
+ price: number;
+ unitPrice: number | null;
+ url: string | null;
+ }[];
+};
+
+type ProductQueryData = {
+ product: Product | null;
+};
+
+type ProductQueryVariables = {
+ ean: string;
+};
+
+export const PRODUCT_QUERY: TypedDocumentNode<
+ ProductQueryData,
+ ProductQueryVariables
+> = gql`
+ query Product($ean: String!) {
+ product(ean: $ean) {
+ ean
+ name
+ brand
+ imageUrl
+ category {
+ id
+ name
+ }
+ prices {
+ storeCode
+ price
+ unitPrice
+ url
+ }
+ }
+ }
+`;
\ No newline at end of file