From da69b3027c2328147e0aae3993f8de678037b28a Mon Sep 17 00:00:00 2001 From: Annisa Alexis Muljono Date: Fri, 16 Oct 2026 22:52:53 +0200 Subject: [PATCH] feat(#51): integrate routing, search and navigation --- frontend/index.html | 15 +- frontend/package-lock.json | 37 ++- frontend/package.json | 3 +- frontend/public/favicon.svg | 63 +++- frontend/src/App.css | 82 +---- frontend/src/App.tsx | 249 +++++++------- frontend/src/components/header/Header.css | 23 +- frontend/src/components/header/Header.tsx | 21 +- .../navigationMenu/NavigationMenu.css | 148 +++++++++ .../navigationMenu/NavigationMenu.tsx | 142 ++++++++ .../pages/SearchResults/SearchResultsPage.css | 7 + .../pages/SearchResults/SearchResultsPage.tsx | 86 ++--- frontend/src/pages/home/HomePage.css | 30 ++ frontend/src/pages/home/HomePage.tsx | 303 +++++++++++++++--- frontend/src/pages/home/categoryNavigation.ts | 13 + frontend/src/pages/home/homeCategories.ts | 40 +++ 16 files changed, 981 insertions(+), 281 deletions(-) create mode 100644 frontend/src/components/navigationMenu/NavigationMenu.css create mode 100644 frontend/src/components/navigationMenu/NavigationMenu.tsx create mode 100644 frontend/src/pages/home/categoryNavigation.ts create mode 100644 frontend/src/pages/home/homeCategories.ts 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 b9490c2..e5a8cb3 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,79 +1,27 @@ -/* Hjemskjerm – styling etter Figma (Handlelappen – notatbok). - Farger og skrift kommer fra styles/theme.css. */ +/* ========================================= + APP – FELLES LAYOUT + Issue #51 + ========================================= */ +html, +body, #root { width: 100%; - min-height: 100vh; + min-width: 0; + min-height: 100%; margin: 0; padding: 0; } -main { - width: 100%; - max-width: 1400px; - margin: 0 auto; - padding: 32px; - box-sizing: border-box; -} - -/* 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; -} - -.search button { - background: var(--bg-primary); - color: var(--text-on-dark); +#root { + max-width: none; border: none; - border-radius: 8px; - padding: 10px 16px; - font: 500 15px var(--font-ui); - cursor: pointer; -} - -/* Produktliste */ -.product-list { - list-style: none; - padding: 0; - margin: 0; text-align: left; + display: block; } -.product { - display: flex; - gap: 1rem; - align-items: center; - padding: 0.75rem 0; - border-bottom: 1px solid var(--border-hairline); +body { + background: var(--bg-page); + color: var(--text-primary); + font-family: var(--font-ui); } - -.product img, -.no-image { - width: 64px; - height: 64px; - object-fit: contain; - background: var(--bg-surface); - border: 1px solid var(--border-brand); - border-radius: 6px; - flex-shrink: 0; -} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ab7dfc1..ed57d82 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,123 +1,150 @@ -import { gql } from "@apollo/client"; -import type { TypedDocumentNode } from "@apollo/client"; -import { useQuery } from "@apollo/client/react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; +import NavigationMenu from "./components/navigationMenu/NavigationMenu"; + +import { + BrowserRouter, + Routes, + Route, + useLocation, + useNavigate, +} from "react-router"; + +import Header from "./components/header/Header"; +import HomePage from "./pages/home/HomePage"; +import SearchResultsPage from "./pages/SearchResults/SearchResultsPage"; + +import { + loadSearchPreferences, + saveSearchPreferences, +} from "./pages/SearchResults/searchPreferences"; + +import { createCategoryUrl } from "./pages/home/categoryNavigation"; + import "./App.css"; -type Price = { - storeCode: string; - price: number; -}; - -type Product = { - ean: string; - name: string; - brand: string | null; - imageUrl: string | null; - prices: Price[]; -}; - -type ProductsData = { - products: Product[]; -}; - -type ProductsVars = { - search?: string; - limit: number; -}; - -const PRODUCTS_QUERY: TypedDocumentNode = gql` - query Products($search: String, $limit: Int) { - products(search: $search, limit: $limit) { - ean - name - brand - imageUrl - prices { - storeCode - price - } - } - } -`; +function AppLayout() { + const location = useLocation(); + const navigate = useNavigate(); + const [isMenuOpen, setIsMenuOpen] = useState(false); -// Finner laveste pris blant butikkene, eller null hvis varen ikke har pris -function lowestPrice(prices: Price[]): number | null { - if (prices.length === 0) return null; - return Math.min(...prices.map((p) => p.price)); -} + const isHome = location.pathname === "/"; + + const searchParams = new URLSearchParams(location.search); + const searchValue = searchParams.get("q") ?? ""; + + // Felles søkefunksjon for header og startside. + const handleSearch = (value: string) => { + const search = value.trim(); -function App() { - const [input, setInput] = useState(""); - const [search, setSearch] = useState(""); + if (!search) return; - // Debounce: venter 400 ms etter siste tastetrykk før vi søker, - // slik at vi ikke sender en request for hver bokstav. - useEffect(() => { - const timer = setTimeout(() => setSearch(input.trim()), 400); - return () => clearTimeout(timer); - }, [input]); + const preferences = loadSearchPreferences(); - const { data, loading, error } = useQuery(PRODUCTS_QUERY, { - variables: { search: search || undefined, limit: 20 }, - }); + if (preferences.searchTerm !== search) { + saveSearchPreferences({ + searchTerm: search, + selectedCategories: [], + selectedStores: [], + sort: "relevance", + currentPage: 1, + }); + } + + navigate(`/search?q=${encodeURIComponent(search)}`); + }; + + // Navigasjon fra kategorikort på startsiden. + const handleCategorySelect = (categoryIds: string[]) => { + if (categoryIds.length === 0) return; + + saveSearchPreferences({ + searchTerm: "", + selectedCategories: categoryIds, + selectedStores: [], + sort: "relevance", + currentPage: 1, + }); + + navigate(createCategoryUrl(categoryIds)); + + // Start øverst når en kategori åpnes. + window.scrollTo({ + top: 0, + left: 0, + behavior: "instant", + }); + }; + + const handleOpenCatalog = () => { + saveSearchPreferences({ + searchTerm: "", + selectedCategories: [], + selectedStores: [], + sort: "relevance", + currentPage: 1, + }); + + window.scrollTo({ + top: 0, + left: 0, + behavior: "instant", + }); + }; return ( -
-

Handlelappen

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

Laster varer...

} - - {error &&

Kunne ikke hente varer: {error.message}

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

Ingen varer funnet

- )} - - {data && data.products.length > 0 && ( -
    - {data.products.map((product) => { - const price = lowestPrice(product.prices); - return ( -
  • - {product.imageUrl ? ( - {product.name} - ) : ( -
  • - ); - })} -
- )} -
+ + + } + /> + + +

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 dcfac5a..b8d28c4 100644 --- a/frontend/src/components/header/Header.css +++ b/frontend/src/components/header/Header.css @@ -181,15 +181,23 @@ /* 7. Ekstra små skjermer */ +/* 7. Ekstra små skjermer */ + @media (max-width: 360px) { - .header__logo .logo { + .header { + padding-left: 10px; + padding-right: 10px; gap: 6px; - font-size: 15px; + } + + .header__logo .logo { + gap: 5px; + font-size: 13px; } .header__logo .logo__icon { - width: 24px; - height: 24px; + width: 22px; + height: 22px; } .header__actions { @@ -197,8 +205,9 @@ } .header__list-button { - font-size: 11px; - padding-left: 8px; - padding-right: 8px; + font-size: 10px; + padding-left: 7px; + padding-right: 7px; } } + diff --git a/frontend/src/components/header/Header.tsx b/frontend/src/components/header/Header.tsx index 94b7bd4..278d580 100644 --- a/frontend/src/components/header/Header.tsx +++ b/frontend/src/components/header/Header.tsx @@ -1,3 +1,4 @@ +import { Link } from "react-router"; import { useState } from "react"; import type { FormEvent } from "react"; @@ -11,6 +12,7 @@ import "./Header.css"; type HeaderProps = { variant?: "home" | "search"; searchValue?: string; + onOpenCatalog?: () => void; onSearch?: (value: string) => void; onOpenList?: () => void; onOpenMenu?: () => void; @@ -21,6 +23,7 @@ type HeaderProps = { export default function Header({ variant = "home", searchValue = "", + onOpenCatalog, onSearch, onOpenList, onOpenMenu, @@ -47,9 +50,21 @@ export default function Header({ {variant === "home" ? ( ) : (
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/SearchResultsPage.css b/frontend/src/pages/SearchResults/SearchResultsPage.css index e2399ab..cbb78df 100644 --- a/frontend/src/pages/SearchResults/SearchResultsPage.css +++ b/frontend/src/pages/SearchResults/SearchResultsPage.css @@ -1312,4 +1312,11 @@ .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 index ada644a..ed2c727 100644 --- a/frontend/src/pages/SearchResults/SearchResultsPage.tsx +++ b/frontend/src/pages/SearchResults/SearchResultsPage.tsx @@ -1,6 +1,4 @@ - import { - useCallback, useEffect, useLayoutEffect, useRef, @@ -13,7 +11,6 @@ import type { TypedDocumentNode } from "@apollo/client"; import { useQuery } from "@apollo/client/react"; import SearchFilters from "./SearchFilters"; -import SearchInput from "./SearchInput"; import SearchPagination from "./SearchPagination"; import SearchProductList from "./SearchProductList"; import SearchSortSelect from "./SearchSortSelect"; @@ -38,6 +35,8 @@ import "./SearchResultsPage.css"; import SearchEmptyState from "./SearchEmptyState"; +import { useSearchParams } from "react-router"; + const PAGE_SIZE = 20; // Typer for spørringen som henter alle kategorinavn @@ -160,17 +159,20 @@ function normalizeProducts( } 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, setSearchTerm] = useState( - initialPreferences.searchTerm, - ); + const searchTerm = urlSearchTerm ?? ""; - const [currentPage, setCurrentPage] = useState( - initialPreferences.currentPage, + const [currentPage, setCurrentPage] = useState(() => + initialPreferences.searchTerm === searchTerm + ? initialPreferences.currentPage + : 1, ); const [sort, setSort] = useState( @@ -178,13 +180,25 @@ export default function SearchResultsPage() { ); const [selectedCategories, setSelectedCategories] = - useState( - initialPreferences.selectedCategories, - ); + useState(() => { + const urlCategories = searchParams.getAll("category"); + + if (urlCategories.length > 0) { + return urlCategories; + } + + if (urlSearchTerm === null) { + return []; + } + + return initialPreferences.selectedCategories; + }); const [selectedStores, setSelectedStores] = - useState( - initialPreferences.selectedStores, + useState(() => + urlSearchTerm === null + ? [] + : initialPreferences.selectedStores, ); const [knownCategoryNames, setKnownCategoryNames] = @@ -195,6 +209,7 @@ export default function SearchResultsPage() { // 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); @@ -241,15 +256,6 @@ export default function SearchResultsPage() { currentPage, ]); - // Fjern midlertidig minimumshøyde når lasting er ferdig. - const handleSearchChange = useCallback( - (value: string) => { - setSearchTerm(value); - setCurrentPage(1); - }, - [], - ); - const handleCategoriesChange = ( values: string[], ) => { @@ -273,7 +279,14 @@ export default function SearchResultsPage() { }; const handlePageChange = (page: number) => { + if (page === currentPage) return; + setCurrentPage(page); + + productsSectionRef.current?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); }; const resetFilters = () => { @@ -285,6 +298,12 @@ export default function SearchResultsPage() { 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, @@ -299,7 +318,7 @@ export default function SearchResultsPage() { limit: PAGE_SIZE, offset: (currentPage - 1) * PAGE_SIZE, }, - skip: !hasSearch, + skip: !shouldFetchProducts, }); // Hent dynamiske filtertall. @@ -314,7 +333,7 @@ export default function SearchResultsPage() { categories: selectedCategories, storeCodes: selectedStores, }, - skip: !hasSearch, + skip: !shouldFetchProducts, }); // Mens nye filtertall lastes, viser vi de forrige. Da beholder filterlisten @@ -417,12 +436,6 @@ export default function SearchResultsPage() { return (
-
- -

@@ -435,13 +448,15 @@ export default function SearchResultsPage() { Treff for «{searchTerm}» {storeHeading} + ) : hasCategorySelection ? ( + "Produkter i valgte kategorier" ) : ( - "Søk etter matvarer" + "Produktkatalog" )}

- {hasSearch && !loading && !error + {shouldFetchProducts && !loading && !error ? `${totalProducts} produkter funnet` : "Søkeresultater"}

@@ -499,6 +514,7 @@ export default function SearchResultsPage() {
@@ -588,11 +604,7 @@ export default function SearchResultsPage() { : undefined, }} > - {!hasSearch ? ( -

- Skriv inn et søkeord for å finne matvarer. -

- ) : loading ? ( + {loading ? (

Laster produkter...

) : error ? (

diff --git a/frontend/src/pages/home/HomePage.css b/frontend/src/pages/home/HomePage.css index 4f4e1f4..4eb7e5c 100644 --- a/frontend/src/pages/home/HomePage.css +++ b/frontend/src/pages/home/HomePage.css @@ -299,4 +299,34 @@ .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 index a557a81..c1c709e 100644 --- a/frontend/src/pages/home/HomePage.tsx +++ b/frontend/src/pages/home/HomePage.tsx @@ -1,3 +1,12 @@ + +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"; @@ -5,7 +14,11 @@ import "../../components/noteItem/NoteItem.css"; import "../../components/checkbox/Checkbox.css"; import "../../components/categoryCard/CategoryCard.css"; -// Midlertidige data. Byttes ut med ekte data fra backend senere. +// ========================================== +// MIDLERTIDIG HANDLELAPP +// Erstattes med ekte handlelistedata senere. +// ========================================== + const noteItems = [ { name: "Lettmelk", count: "×2" }, { name: "Norvegia 26 %" }, @@ -14,35 +27,119 @@ const noteItems = [ { name: "Tomater", count: "×4" }, ]; -const categories = [ - { - name: "Frukt og grønt", - description: "Frukt, grønnsaker, urter", - count: 214, - }, - { name: "Meieri og egg", description: "Melk, yoghurt, egg", count: 96 }, - { name: "Ost", description: "Hvitost, brunost, smøreost", count: 148 }, - { - name: "Brød og bakst", - description: "Brød, knekkebrød, lefser", - count: 122, - }, - { - name: "Kjøtt og fisk", - description: "Kylling, kjøttdeig, laks", - count: 187, - }, - { name: "Tørrvarer", description: "Pasta, ris, havregryn", count: 263 }, - { name: "Frysevarer", description: "Pizza, grønnsaker, is", count: 91 }, - { name: "Drikke", description: "Kaffe, te, juice", count: 175 }, -]; +// ========================================== +// 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 + // ========================================== -function HomePage() { return (

+ {/* ====================================== + HERO – OVERSKRIFT OG SØKEFELT + ====================================== */} +
-

HANDLELAPPEN · VOL. 01 · HØST 2026

+

+ HANDLELAPPEN · VOL. 01 · HØST 2026 +

Hva skal på
@@ -50,19 +147,33 @@ function HomePage() {

- Søk etter en vare, eller bla deg gjennom en kategori. Lappen holder - styr på resten, og viser hva det koster i hver kjede. + Søk etter en vare, eller bla deg gjennom en kategori. + Lappen holder styr på resten, og viser hva det + koster i hver kjede.

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

- 01 +

+ + 01 + Kategorier

+

{categories.length} AVDELINGER · BLA I KATALOGEN →

+ {categoriesError && ( +

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

+ )} + + {categoriesLoading && ( +

Henter kategorier...

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

Ingen kategorier tilgjengelige akkurat nå.

+ )} +
    {categories.map((category, index) => (
  • -
+ {/* ====================================== + BUNNTEKST + ====================================== */} +
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", + }, +];