From 34deeaf2cd987f9a3210b1247a3186490794ff73 Mon Sep 17 00:00:00 2001 From: Annisa Alexis Muljono Date: Wed, 14 Oct 2026 06:15:25 +0200 Subject: [PATCH 1/6] wip: implement search results page for issue #37 --- frontend/search-preview.html | 12 + .../pages/SearchResults/SearchEmptyState.tsx | 0 .../src/pages/SearchResults/SearchFilters.tsx | 63 ++++ .../pages/SearchResults/SearchProductList.tsx | 98 +++++ .../pages/SearchResults/SearchResultsPage.css | 341 ++++++++++++++++++ .../pages/SearchResults/SearchResultsPage.tsx | 71 ++++ .../src/pages/SearchResults/searchQueries.ts | 37 ++ .../src/pages/SearchResults/searchTypes.ts | 26 ++ frontend/src/search-preview.tsx | 15 + 9 files changed, 663 insertions(+) create mode 100644 frontend/search-preview.html create mode 100644 frontend/src/pages/SearchResults/SearchEmptyState.tsx create mode 100644 frontend/src/pages/SearchResults/SearchFilters.tsx create mode 100644 frontend/src/pages/SearchResults/SearchProductList.tsx create mode 100644 frontend/src/pages/SearchResults/SearchResultsPage.css create mode 100644 frontend/src/pages/SearchResults/SearchResultsPage.tsx create mode 100644 frontend/src/pages/SearchResults/searchQueries.ts create mode 100644 frontend/src/pages/SearchResults/searchTypes.ts create mode 100644 frontend/src/search-preview.tsx diff --git a/frontend/search-preview.html b/frontend/search-preview.html new file mode 100644 index 0000000..6f42d7e --- /dev/null +++ b/frontend/search-preview.html @@ -0,0 +1,12 @@ + + + + + + Handlelappen – Søkeresultater + + +
+ + + \ No newline at end of file diff --git a/frontend/src/pages/SearchResults/SearchEmptyState.tsx b/frontend/src/pages/SearchResults/SearchEmptyState.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/pages/SearchResults/SearchFilters.tsx b/frontend/src/pages/SearchResults/SearchFilters.tsx new file mode 100644 index 0000000..606a41d --- /dev/null +++ b/frontend/src/pages/SearchResults/SearchFilters.tsx @@ -0,0 +1,63 @@ + +type FilterOption = { + label: string; + count: number; +}; + +const categories: FilterOption[] = [ + { label: "Ost", count: 148 }, + { label: "Pålegg", count: 41 }, + { label: "Meieri", count: 17 }, + { label: "Frysevarer", count: 8 }, +]; + +const stores: FilterOption[] = [ + { label: "Kiwi", count: 132 }, + { label: "Rema 1000", count: 158 }, + { label: "Extra", count: 121 }, + { label: "Meny", count: 196 }, + { label: "Spar", count: 143 }, + { label: "Bunnpris", count: 64 }, +]; + +const labels: FilterOption[] = [ + { label: "Økologisk", count: 12 }, + { label: "Laktosefri", count: 23 }, + { label: "Norskprodusert", count: 131 }, +]; + +type FilterGroupProps = { + title: string; + letter: string; + options: FilterOption[]; +}; + +function FilterGroup({ title, letter, options }: FilterGroupProps) { + return ( +
+ + {letter} {title} + + + {options.map((option) => ( + + ))} +
+ ); +} + +export default function SearchFilters() { + return ( +
+ + + +
+ ); +} diff --git a/frontend/src/pages/SearchResults/SearchProductList.tsx b/frontend/src/pages/SearchResults/SearchProductList.tsx new file mode 100644 index 0000000..aeea7cf --- /dev/null +++ b/frontend/src/pages/SearchResults/SearchProductList.tsx @@ -0,0 +1,98 @@ + +import type { SearchProduct, ProductPrice } from "./searchTypes"; + +type SearchProductListProps = { + products: SearchProduct[]; + selectedStore?: string | null; + onAddProduct?: (product: SearchProduct) => void; +}; + +const priceFormatter = new Intl.NumberFormat("nb-NO", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +function getDisplayPrice( + prices: ProductPrice[], + selectedStore?: string | null, +): ProductPrice | null { + if (selectedStore) { + return ( + prices.find((price) => price.storeCode === selectedStore) ?? null + ); + } + + if (prices.length === 0) { + return null; + } + + return prices.reduce((lowest, current) => + current.price < lowest.price ? current : lowest, + ); +} + +export default function SearchProductList({ + products, + selectedStore, + onAddProduct, +}: SearchProductListProps) { + return ( +
+ + +
    + {products.map((product, index) => { + const displayPrice = getDisplayPrice( + product.prices, + selectedStore, + ); + + return ( +
  1. + + {String(index + 1).padStart(3, "0")} + + +
    + {product.name} + {product.brand && ( + + {product.brand} + + )} +
    + + + {displayPrice?.unitPrice != null + ? priceFormatter.format(displayPrice.unitPrice) + : "—"} + + + + {displayPrice + ? priceFormatter.format(displayPrice.price) + : "Pris mangler"} + + + +
  2. + ); + })} +
+
+ ); +} diff --git a/frontend/src/pages/SearchResults/SearchResultsPage.css b/frontend/src/pages/SearchResults/SearchResultsPage.css new file mode 100644 index 0000000..a35d6a6 --- /dev/null +++ b/frontend/src/pages/SearchResults/SearchResultsPage.css @@ -0,0 +1,341 @@ +/* Grunnstruktur for søkeresultatsiden */ + +.search-results-page, +.search-results-page * { + box-sizing: border-box; +} + +.search-results-page { + font-family: var(--font-ui); + text-align: left; +} + +.search-results-page h1, +.search-results-page h2, +.search-results-page h3 { + font-family: var(--font-ui); +} + +.search-results-page { + width: 100%; + max-width: 1440px; + margin: 0 auto; + padding: 48px 40px 80px; + color: #202020; +} + +.search-results-page h1, +.search-results-page h2, +.search-results-page h3 { + color: #202020; +} + +.search-results-page .search-results-header h1 em { + color: #750021; +} + +.search-results-header { + padding-bottom: 32px; + margin-bottom: 32px; + border-bottom: 1px solid #e5e5e5; +} + +.search-results-section-label { + margin: 0 0 12px; + color: #d60055; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.12em; +} + +.search-results-header h1 { + margin: 0 0 12px; + font-size: clamp(32px, 4vw, 48px); + font-weight: 500; + letter-spacing: -0.04em; +} + +.search-results-header h1 em { + color: #750021; + font-family: Georgia, serif; + font-weight: 400; +} + +.search-results-count { + margin: 0; + color: #777; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.search-results-layout { + display: grid; + grid-template-columns: 200px minmax(0, 1fr) 300px; + gap: 32px; + align-items: start; +} + +.search-results-filters, +.search-results-content, +.search-results-shopping-list { + min-width: 0; +} + +.search-results-filters h2, +.search-results-shopping-list h2 { + margin-top: 0; + font-size: 16px; +} + +.search-results-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid #d9d9d9; +} + +.search-results-toolbar h2 { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +.search-results-list { + min-height: 300px; +} + +.search-results-shopping-list { + border: 1px solid #750021; + padding: 24px; + background: #fff; + box-shadow: 6px 6px 0 #f6dce6; +} + +@media (max-width: 1100px) { + .search-results-layout { + grid-template-columns: 180px minmax(0, 1fr); + } + + .search-results-shopping-list { + grid-column: 1 / -1; + } +} + +@media (max-width: 700px) { + .search-results-page { + padding: 24px 16px 48px; + } + + .search-results-layout { + grid-template-columns: 1fr; + gap: 24px; + } + + .search-results-header { + margin-bottom: 24px; + } + + .search-results-filters, + .search-results-shopping-list { + grid-column: 1; + } +} + + +/* Filterpanel */ + +.search-filters { + display: flex; + flex-direction: column; + gap: 30px; + text-align: left; +} + +.search-filter-group { + border: 0; + margin: 0; + padding: 0; + min-width: 0; +} + +.search-filter-heading { + width: 100%; + padding-bottom: 10px; + margin-bottom: 12px; + border-bottom: 1px solid #e5e5e5; + color: #222; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.search-filter-heading span { + color: #d60055; + margin-right: 6px; +} + +.search-filter-option { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 7px 0; + font-size: 13px; + color: #222; + cursor: pointer; +} + +.search-filter-option-name { + display: flex; + align-items: center; + gap: 9px; +} + +.search-filter-option input { + width: 15px; + height: 15px; + margin: 0; + accent-color: #750021; + cursor: pointer; +} + +.search-filter-count { + color: #888; + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.search-filter-option:hover { + color: #a0003d; +} + +.search-filter-option:focus-within { + outline: 2px solid #d60055; + outline-offset: 3px; + border-radius: 2px; +} + + +/* Produktliste */ + +.search-product-table { + width: 100%; +} + +.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; +} + +.search-product-table-header { + padding: 12px 0; + border-bottom: 1px solid var(--blekk); + font-family: var(--font-mono); + font-size: 10px; + color: #888; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.search-product-items { + list-style: none; + padding: 0; + margin: 0; +} + +.search-product-row { + min-height: 70px; + padding: 12px 0; + border-bottom: 1px solid #e5e5e5; +} + +.search-product-number { + font-family: var(--font-mono); + font-size: 11px; + color: #999; +} + +.search-product-info { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.search-product-info strong { + font-size: 14px; + font-weight: 500; + line-height: 1.3; + color: var(--blekk); +} + +.search-product-brand { + font-family: var(--font-mono); + font-size: 10px; + color: #888; + text-transform: uppercase; +} + +.search-product-unit-price { + font-family: var(--font-mono); + font-size: 11px; + color: #888; +} + +.search-product-price { + font-size: 15px; + font-weight: 500; + color: var(--blekk); + font-variant-numeric: tabular-nums; +} + +.search-product-add-button { + padding: 10px 8px; + border: 1px solid #e5e5e5; + border-radius: 7px; + background: var(--hvit); + color: var(--blekk); + font: 500 12px var(--font-ui); + cursor: pointer; +} + +.search-product-add-button:hover:not(:disabled) { + background: var(--rosa); + border-color: var(--burgunder); +} + +.search-product-add-button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.search-product-add-button:focus-visible { + outline: 2px solid var(--bringebaer); + outline-offset: 3px; +} + +@media (max-width: 900px) { + .search-product-table-header, + .search-product-row { + grid-template-columns: 28px minmax(0, 1fr) 80px; + gap: 10px; + } + + .search-product-table-header span:nth-child(3), + .search-product-table-header span:nth-child(5), + .search-product-unit-price { + display: none; + } + + .search-product-add-button { + grid-column: 2 / -1; + justify-self: start; + } +} diff --git a/frontend/src/pages/SearchResults/SearchResultsPage.tsx b/frontend/src/pages/SearchResults/SearchResultsPage.tsx new file mode 100644 index 0000000..e77dc64 --- /dev/null +++ b/frontend/src/pages/SearchResults/SearchResultsPage.tsx @@ -0,0 +1,71 @@ +import "./SearchResultsPage.css"; +import SearchFilters from "./SearchFilters"; +import SearchProductList from "./SearchProductList"; +import { useQuery } from "@apollo/client/react"; +import { SEARCH_PRODUCTS_QUERY } from "./searchQueries"; + +export default function SearchResultsPage() { + const { data, loading, error } = useQuery(SEARCH_PRODUCTS_QUERY, { + variables: { + search: "ost", + limit: 20, + offset: 0, + }, + }); + + const products = data?.products ?? []; + return ( +
+
+

02 — SØK

+

+ Treff for «ost» +

+

+ Søkeresultater +

+
+ +
+ + +
+
+

Produkter

+

Sortering kommer her.

+
+ +
+ {loading ? ( +

Laster produkter...

+ ) : error ? ( +

+ Kunne ikke hente produkter. Prøv igjen senere. +

+ ) : products.length > 0 ? ( + + ) : ( +

Ingen produkter funnet for «ost».

+ )} +
+
+ + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/SearchResults/searchQueries.ts b/frontend/src/pages/SearchResults/searchQueries.ts new file mode 100644 index 0000000..cdacffd --- /dev/null +++ b/frontend/src/pages/SearchResults/searchQueries.ts @@ -0,0 +1,37 @@ +import { gql } from "@apollo/client"; +import type { TypedDocumentNode } from "@apollo/client"; + +import type { + SearchProductsData, + SearchProductsVariables, +} from "./searchTypes"; + +export const SEARCH_PRODUCTS_QUERY: TypedDocumentNode< + SearchProductsData, + SearchProductsVariables +> = gql` + query SearchProducts( + $search: String + $category: ID + $limit: Int + $offset: Int + ) { + products( + search: $search + category: $category + limit: $limit + offset: $offset + ) { + ean + name + brand + imageUrl + categoryId + prices { + storeCode + price + unitPrice + } + } + } +`; diff --git a/frontend/src/pages/SearchResults/searchTypes.ts b/frontend/src/pages/SearchResults/searchTypes.ts new file mode 100644 index 0000000..9db0202 --- /dev/null +++ b/frontend/src/pages/SearchResults/searchTypes.ts @@ -0,0 +1,26 @@ + +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 SearchProductsData = { + products: SearchProduct[]; +}; + +export type SearchProductsVariables = { + search?: string; + category?: string; + limit?: number; + offset?: number; +}; diff --git a/frontend/src/search-preview.tsx b/frontend/src/search-preview.tsx new file mode 100644 index 0000000..5821d60 --- /dev/null +++ b/frontend/src/search-preview.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { ApolloProvider } from "@apollo/client/react"; + +import { apolloClient } from "./apolloClient"; +import SearchResultsPage from "./pages/SearchResults/SearchResultsPage"; +import "./tema.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + , +); From bcaf1d140516dc0a685cb193e52d186d4b2ae7dd Mon Sep 17 00:00:00 2001 From: Annisa Alexis Muljono Date: Wed, 14 Oct 2026 08:02:48 +0200 Subject: [PATCH 2/6] feat: add store filtering for issue #37 --- backend/src/index.ts | 80 ++++- .../src/pages/SearchResults/SearchFilters.tsx | 127 ++++--- .../src/pages/SearchResults/SearchInput.tsx | 39 +++ .../pages/SearchResults/SearchPagination.tsx | 76 ++++ .../pages/SearchResults/SearchResultsPage.css | 330 ++++++++++++------ .../pages/SearchResults/SearchResultsPage.tsx | 143 +++++++- .../src/pages/SearchResults/searchQueries.ts | 8 + .../src/pages/SearchResults/searchTypes.ts | 2 + 8 files changed, 632 insertions(+), 173 deletions(-) create mode 100644 frontend/src/pages/SearchResults/SearchInput.tsx create mode 100644 frontend/src/pages/SearchResults/SearchPagination.tsx diff --git a/backend/src/index.ts b/backend/src/index.ts index def069b..6dee500 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -31,10 +31,17 @@ const typeDefs = `#graphql products( search: String category: ID + storeCode: String limit: Int = 20 offset: Int = 0 ): [Product!]! + productsCount( + search: String + category: ID + storeCode: String + ): Int! + product(ean: String!): Product categories: [Category!]! @@ -103,44 +110,91 @@ const resolvers = { return result.rows[0] ?? null; }, - products: async ( _parent: unknown, args: { search?: string; category?: string; + storeCode?: string; limit?: number; offset?: number; }, ) => { const search = args.search ?? null; const category = args.category ?? null; + const storeCode = args.storeCode ?? null; const limit = args.limit ?? 20; const offset = args.offset ?? 0; const result = await pool.query( ` SELECT - ean, - name, - brand, - image_url AS "imageUrl", - category_id AS "categoryId" - FROM products + p.ean, + p.name, + p.brand, + p.image_url AS "imageUrl", + p.category_id AS "categoryId" + FROM products p WHERE - ($1::text IS NULL OR name ILIKE '%' || $1 || '%') + ($1::text IS NULL OR p.name ILIKE '%' || $1 || '%') AND - ($2::integer IS NULL OR category_id = $2) - ORDER BY name - LIMIT $3 - OFFSET $4 + ($2::integer IS NULL OR p.category_id = $2) + AND + ( + $3::text IS NULL + OR EXISTS ( + SELECT 1 + FROM prices pr + WHERE pr.ean = p.ean + AND pr.store_code = $3 + ) + ) + ORDER BY p.name, p.ean + LIMIT $4 + OFFSET $5 `, - [search, category, limit, offset], + [search, category, storeCode, limit, offset], ); return result.rows; }, + productsCount: async ( + _parent: unknown, + args: { + search?: string; + category?: string; + storeCode?: string; + }, + ) => { + const search = args.search ?? null; + const category = args.category ?? null; + const storeCode = args.storeCode ?? null; + + const result = await pool.query( + ` + SELECT COUNT(*)::integer AS count + FROM products p + WHERE + ($1::text IS NULL OR p.name ILIKE '%' || $1 || '%') + AND + ($2::integer IS NULL OR p.category_id = $2) + AND + ( + $3::text IS NULL + OR EXISTS ( + SELECT 1 + FROM prices pr + WHERE pr.ean = p.ean + AND pr.store_code = $3 + ) + ) + `, + [search, category, storeCode], + ); + + return result.rows[0].count; + }, shoppingList: async ( _parent: unknown, diff --git a/frontend/src/pages/SearchResults/SearchFilters.tsx b/frontend/src/pages/SearchResults/SearchFilters.tsx index 606a41d..f40d052 100644 --- a/frontend/src/pages/SearchResults/SearchFilters.tsx +++ b/frontend/src/pages/SearchResults/SearchFilters.tsx @@ -1,63 +1,106 @@ type FilterOption = { label: string; - count: number; + value: string; + count?: number; }; const categories: FilterOption[] = [ - { label: "Ost", count: 148 }, - { label: "Pålegg", count: 41 }, - { label: "Meieri", count: 17 }, - { label: "Frysevarer", count: 8 }, + { label: "Ost", value: "ost" }, + { label: "Pålegg", value: "palegg" }, + { label: "Meieri", value: "meieri" }, + { label: "Frysevarer", value: "frysevarer" }, ]; const stores: FilterOption[] = [ - { label: "Kiwi", count: 132 }, - { label: "Rema 1000", count: 158 }, - { label: "Extra", count: 121 }, - { label: "Meny", count: 196 }, - { label: "Spar", count: 143 }, - { label: "Bunnpris", count: 64 }, + { label: "Kiwi", value: "KIWI" }, + { label: "Meny", value: "MENY_NO" }, + { label: "Spar", value: "SPAR_NO" }, + { label: "Bunnpris", value: "BUNNPRIS" }, + { label: "Coop", value: "COOP_NO" }, + { label: "Joker", value: "JOKER_NO" }, + { label: "Oda", value: "ODA_NO" }, + { label: "Europris", value: "EUROPRIS_NO" }, ]; const labels: FilterOption[] = [ - { label: "Økologisk", count: 12 }, - { label: "Laktosefri", count: 23 }, - { label: "Norskprodusert", count: 131 }, + { label: "Økologisk", value: "organic" }, + { label: "Laktosefri", value: "lactose-free" }, + { label: "Norskprodusert", value: "norwegian" }, ]; -type FilterGroupProps = { - title: string; - letter: string; - options: FilterOption[]; +type SearchFiltersProps = { + selectedStore: string | null; + onStoreChange: (store: string | null) => void; }; -function FilterGroup({ title, letter, options }: FilterGroupProps) { - return ( -
- - {letter} {title} - - - {options.map((option) => ( - - ))} -
- ); -} - -export default function SearchFilters() { +export default function SearchFilters({ + selectedStore, + onStoreChange, +}: SearchFiltersProps) { return (
- - - +
+ + A Kategori + + + {categories.map((category) => ( + + ))} +
+ +
+ + B Butikkjede + + + {stores.map((store) => ( + + ))} +
+ +
+ + C Merking + + + {labels.map((label) => ( + + ))} +
); } diff --git a/frontend/src/pages/SearchResults/SearchInput.tsx b/frontend/src/pages/SearchResults/SearchInput.tsx new file mode 100644 index 0000000..2fa680a --- /dev/null +++ b/frontend/src/pages/SearchResults/SearchInput.tsx @@ -0,0 +1,39 @@ + +import { useEffect, useState } from "react"; +import "../../components/searchfield/Searchfield.css"; + +type SearchInputProps = { + value: string; + onSearchChange: (value: string) => void; +}; + +export default function SearchInput({ + value, + onSearchChange, +}: SearchInputProps) { + const [inputValue, setInputValue] = useState(value); + + useEffect(() => { + const timeout = window.setTimeout(() => { + onSearchChange(inputValue.trim()); + }, 350); + + return () => window.clearTimeout(timeout); + }, [inputValue, onSearchChange]); + + return ( +
+
+ ); +} + diff --git a/frontend/src/pages/SearchResults/SearchPagination.tsx b/frontend/src/pages/SearchResults/SearchPagination.tsx new file mode 100644 index 0000000..c28b03c --- /dev/null +++ b/frontend/src/pages/SearchResults/SearchPagination.tsx @@ -0,0 +1,76 @@ + +type SearchPaginationProps = { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +}; + +export default function SearchPagination({ + currentPage, + totalPages, + onPageChange, +}: SearchPaginationProps) { + if (totalPages <= 1) { + return null; + } + + const pages = Array.from( + new Set([ + 1, + 2, + currentPage - 1, + currentPage, + currentPage + 1, + totalPages, + ]), + ) + .filter((page) => page >= 1 && page <= totalPages) + .sort((a, b) => a - b); + + return ( + + ); +} diff --git a/frontend/src/pages/SearchResults/SearchResultsPage.css b/frontend/src/pages/SearchResults/SearchResultsPage.css index a35d6a6..61bc247 100644 --- a/frontend/src/pages/SearchResults/SearchResultsPage.css +++ b/frontend/src/pages/SearchResults/SearchResultsPage.css @@ -1,51 +1,54 @@ -/* Grunnstruktur for søkeresultatsiden */ + +/* ========================================= + HANDLELAPPEN – SØKERESULTATER + Issue #37 + + Farger og fonter hentes fra theme.css. + Fungerer i både light og dark mode. + ========================================= */ + +/* Grunnstruktur */ .search-results-page, .search-results-page * { box-sizing: border-box; } -.search-results-page { - font-family: var(--font-ui); - text-align: left; -} - -.search-results-page h1, -.search-results-page h2, -.search-results-page h3 { - font-family: var(--font-ui); -} - .search-results-page { width: 100%; max-width: 1440px; margin: 0 auto; padding: 48px 40px 80px; - color: #202020; + background: var(--bg-page); + color: var(--text-primary); + font-family: var(--font-ui); + text-align: left; } .search-results-page h1, .search-results-page h2, .search-results-page h3 { - color: #202020; + margin-top: 0; + color: var(--text-primary); + font-family: var(--font-ui); } -.search-results-page .search-results-header h1 em { - color: #750021; -} +/* Overskrift */ .search-results-header { padding-bottom: 32px; margin-bottom: 32px; - border-bottom: 1px solid #e5e5e5; + border-bottom: 1px solid var(--border-hairline); } .search-results-section-label { margin: 0 0 12px; - color: #d60055; + color: var(--text-accent); + font-family: var(--font-mono); font-size: 11px; - font-weight: 600; + font-weight: 500; letter-spacing: 0.12em; + text-transform: uppercase; } .search-results-header h1 { @@ -53,22 +56,27 @@ font-size: clamp(32px, 4vw, 48px); font-weight: 500; letter-spacing: -0.04em; + line-height: 1.15; } .search-results-header h1 em { - color: #750021; - font-family: Georgia, serif; + color: var(--text-brand); + font-family: var(--font-accent); + font-style: italic; font-weight: 400; } .search-results-count { margin: 0; - color: #777; - font-size: 12px; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; } +/* Hovedlayout */ + .search-results-layout { display: grid; grid-template-columns: 200px minmax(0, 1fr) 300px; @@ -82,19 +90,16 @@ min-width: 0; } -.search-results-filters h2, -.search-results-shopping-list h2 { - margin-top: 0; - font-size: 16px; -} +/* Verktøylinje */ .search-results-toolbar { display: flex; justify-content: space-between; align-items: center; + gap: 16px; margin-bottom: 20px; padding-bottom: 12px; - border-bottom: 1px solid #d9d9d9; + border-bottom: 1px solid var(--border-hairline); } .search-results-toolbar h2 { @@ -107,45 +112,30 @@ min-height: 300px; } +/* Midlertidig handlelisteområde. + Erstattes senere med Lindes komponent. */ + .search-results-shopping-list { - border: 1px solid #750021; padding: 24px; - background: #fff; - box-shadow: 6px 6px 0 #f6dce6; + background: var(--note-paper); + color: var(--note-ink); + border: 1px solid var(--border-strong); + box-shadow: 6px 6px 0 var(--note-tint); } -@media (max-width: 1100px) { - .search-results-layout { - grid-template-columns: 180px minmax(0, 1fr); - } - - .search-results-shopping-list { - grid-column: 1 / -1; - } +.search-results-shopping-list h2 { + margin: 0 0 12px; + color: var(--note-ink); + font-size: 16px; } -@media (max-width: 700px) { - .search-results-page { - padding: 24px 16px 48px; - } - - .search-results-layout { - grid-template-columns: 1fr; - gap: 24px; - } - - .search-results-header { - margin-bottom: 24px; - } - - .search-results-filters, - .search-results-shopping-list { - grid-column: 1; - } +.search-results-shopping-list p { + color: var(--text-secondary); } - -/* Filterpanel */ +/* ========================================= + FILTERPANEL + ========================================= */ .search-filters { display: flex; @@ -155,27 +145,28 @@ } .search-filter-group { - border: 0; + min-width: 0; margin: 0; padding: 0; - min-width: 0; + border: 0; } .search-filter-heading { width: 100%; padding-bottom: 10px; margin-bottom: 12px; - border-bottom: 1px solid #e5e5e5; - color: #222; + border-bottom: 1px solid var(--border-hairline); + color: var(--text-primary); + font-family: var(--font-mono); font-size: 11px; - font-weight: 600; + font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; } .search-filter-heading span { - color: #d60055; margin-right: 6px; + color: var(--text-accent); } .search-filter-option { @@ -184,8 +175,8 @@ align-items: center; gap: 12px; padding: 7px 0; + color: var(--text-primary); font-size: 13px; - color: #222; cursor: pointer; } @@ -195,32 +186,55 @@ gap: 9px; } +/* Samme visuelle prinsipp som Lindes Checkbox */ + .search-filter-option input { - width: 15px; - height: 15px; + appearance: none; + display: inline-grid; + place-items: center; + flex-shrink: 0; + width: 16px; + height: 16px; margin: 0; - accent-color: #750021; + background: var(--bg-surface); + border: 1.25px solid var(--border-brand); + border-radius: 3px; cursor: pointer; } +.search-filter-option input:checked { + background: var(--bg-brand); + border-color: var(--bg-brand); +} + +.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); +} + +.search-filter-option input:focus-visible { + outline: 2px solid var(--text-accent); + outline-offset: 2px; +} + .search-filter-count { - color: #888; + color: var(--text-muted); + font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; } .search-filter-option:hover { - color: #a0003d; + color: var(--text-accent); } -.search-filter-option:focus-within { - outline: 2px solid #d60055; - outline-offset: 3px; - border-radius: 2px; -} - - -/* Produktliste */ +/* ========================================= + PRODUKTLISTE + ========================================= */ .search-product-table { width: 100%; @@ -229,37 +243,38 @@ .search-product-table-header, .search-product-row { display: grid; - grid-template-columns: 32px minmax(0, 1fr) 80px 90px 145px; + grid-template-columns: + 32px minmax(0, 1fr) 80px 90px 145px; column-gap: 12px; align-items: center; } .search-product-table-header { padding: 12px 0; - border-bottom: 1px solid var(--blekk); + border-bottom: 1px solid var(--border-strong); + color: var(--text-muted); font-family: var(--font-mono); font-size: 10px; - color: #888; - text-transform: uppercase; letter-spacing: 0.08em; + text-transform: uppercase; } .search-product-items { - list-style: none; - padding: 0; margin: 0; + padding: 0; + list-style: none; } .search-product-row { min-height: 70px; padding: 12px 0; - border-bottom: 1px solid #e5e5e5; + border-bottom: 1px solid var(--border-hairline); } .search-product-number { + color: var(--text-muted); font-family: var(--font-mono); font-size: 11px; - color: #999; } .search-product-info { @@ -270,45 +285,45 @@ } .search-product-info strong { + color: var(--text-primary); font-size: 14px; font-weight: 500; line-height: 1.3; - color: var(--blekk); } .search-product-brand { + color: var(--text-muted); font-family: var(--font-mono); font-size: 10px; - color: #888; text-transform: uppercase; } .search-product-unit-price { + color: var(--text-muted); font-family: var(--font-mono); font-size: 11px; - color: #888; } .search-product-price { + color: var(--text-primary); font-size: 15px; font-weight: 500; - color: var(--blekk); font-variant-numeric: tabular-nums; } .search-product-add-button { padding: 10px 8px; - border: 1px solid #e5e5e5; + background: var(--bg-surface); + color: var(--text-primary); + border: 1px solid var(--border-hairline); border-radius: 7px; - background: var(--hvit); - color: var(--blekk); font: 500 12px var(--font-ui); cursor: pointer; } .search-product-add-button:hover:not(:disabled) { - background: var(--rosa); - border-color: var(--burgunder); + background: var(--bg-tint); + border-color: var(--border-brand); } .search-product-add-button:disabled { @@ -317,10 +332,24 @@ } .search-product-add-button:focus-visible { - outline: 2px solid var(--bringebaer); + outline: 2px solid var(--text-accent); outline-offset: 3px; } +/* ========================================= + RESPONSIVT DESIGN + ========================================= */ + +@media (max-width: 1100px) { + .search-results-layout { + grid-template-columns: 180px minmax(0, 1fr); + } + + .search-results-shopping-list { + grid-column: 1 / -1; + } +} + @media (max-width: 900px) { .search-product-table-header, .search-product-row { @@ -339,3 +368,106 @@ justify-self: start; } } + +@media (max-width: 700px) { + .search-results-page { + padding: 24px 16px 48px; + } + + .search-results-layout { + grid-template-columns: 1fr; + gap: 24px; + } + + .search-results-header { + margin-bottom: 24px; + } + + .search-results-filters, + .search-results-shopping-list { + grid-column: 1; + } +} + +/* Søkefelt på søkeresultatsiden */ + +.search-results-searchbar { + display: flex; + justify-content: center; + margin-bottom: 36px; +} + +.search-results-searchbar .search-field { + width: 100%; + max-width: 640px; +} + +@media (max-width: 700px) { + .search-results-searchbar { + margin-bottom: 24px; + } +} + + +/* Paginering */ + +.search-pagination-count { + margin: 24px 0 12px; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.04em; +} + +.search-pagination { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; +} + +.search-pagination-item { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.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); +} + +.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; +} + +.search-pagination button:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.search-pagination-item > span { + color: var(--text-muted); + font-family: var(--font-mono); +} diff --git a/frontend/src/pages/SearchResults/SearchResultsPage.tsx b/frontend/src/pages/SearchResults/SearchResultsPage.tsx index e77dc64..4b50101 100644 --- a/frontend/src/pages/SearchResults/SearchResultsPage.tsx +++ b/frontend/src/pages/SearchResults/SearchResultsPage.tsx @@ -1,37 +1,117 @@ -import "./SearchResultsPage.css"; + +import { useCallback, useState } from "react"; +import { useQuery } from "@apollo/client/react"; + import SearchFilters from "./SearchFilters"; +import SearchInput from "./SearchInput"; +import SearchPagination from "./SearchPagination"; import SearchProductList from "./SearchProductList"; -import { useQuery } from "@apollo/client/react"; import { SEARCH_PRODUCTS_QUERY } from "./searchQueries"; +import "./SearchResultsPage.css"; + +const PAGE_SIZE = 20; + export default function SearchResultsPage() { - const { data, loading, error } = useQuery(SEARCH_PRODUCTS_QUERY, { - variables: { - search: "ost", - limit: 20, - offset: 0, + const [searchTerm, setSearchTerm] = useState("ost"); + const [currentPage, setCurrentPage] = useState(1); + const [selectedStore, setSelectedStore] = useState(null); + + const handleSearchChange = useCallback((value: string) => { + setSearchTerm(value); + setCurrentPage(1); + }, []); + + const handleStoreChange = (store: string | null) => { + setSelectedStore(store); + setCurrentPage(1); + }; + + const { data, loading, error } = useQuery( + SEARCH_PRODUCTS_QUERY, + { + variables: { + search: searchTerm, + storeCode: selectedStore ?? undefined, + limit: PAGE_SIZE, + offset: (currentPage - 1) * PAGE_SIZE, + }, + skip: searchTerm.length === 0, + }, - }); + ); const products = 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 handlePageChange = (page: number) => { + setCurrentPage(page); + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + 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", + }; + + const selectedStoreName = selectedStore + ? storeNames[selectedStore] + : null; + return (
+
+ +
+
-

02 — SØK

+

+ 02 — SØK +

+

- Treff for «ost» + {searchTerm ? ( + <> + Treff for «{searchTerm}» + {selectedStoreName && ` hos ${selectedStoreName}`} + + ) : ( + "Søk etter matvarer" + )}

+

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

Sortering kommer her.

-
- {loading ? ( +
+ {!searchTerm ? ( +

Skriv inn et søkeord for å finne matvarer.

+ ) : loading ? (

Laster produkter...

) : error ? (

Kunne ikke hente produkter. Prøv igjen senere.

) : products.length > 0 ? ( - + <> + + +

+ Viser {firstProduct}–{lastProduct} av{" "} + {totalProducts} produkter +

+ + + ) : ( -

Ingen produkter funnet for «ost».

+

+ Ingen produkter funnet for «{searchTerm}». + Prøv et annet søkeord. +

)}
@@ -68,4 +173,4 @@ export default function SearchResultsPage() {
); -} \ No newline at end of file +} diff --git a/frontend/src/pages/SearchResults/searchQueries.ts b/frontend/src/pages/SearchResults/searchQueries.ts index cdacffd..4cf9838 100644 --- a/frontend/src/pages/SearchResults/searchQueries.ts +++ b/frontend/src/pages/SearchResults/searchQueries.ts @@ -13,12 +13,14 @@ export const SEARCH_PRODUCTS_QUERY: TypedDocumentNode< query SearchProducts( $search: String $category: ID + $storeCode: String $limit: Int $offset: Int ) { products( search: $search category: $category + storeCode: $storeCode limit: $limit offset: $offset ) { @@ -33,5 +35,11 @@ export const SEARCH_PRODUCTS_QUERY: TypedDocumentNode< unitPrice } } + + productsCount( + search: $search + category: $category + storeCode: $storeCode + ) } `; diff --git a/frontend/src/pages/SearchResults/searchTypes.ts b/frontend/src/pages/SearchResults/searchTypes.ts index 9db0202..628f382 100644 --- a/frontend/src/pages/SearchResults/searchTypes.ts +++ b/frontend/src/pages/SearchResults/searchTypes.ts @@ -16,11 +16,13 @@ export type SearchProduct = { export type SearchProductsData = { products: SearchProduct[]; + productsCount: number; }; export type SearchProductsVariables = { search?: string; category?: string; + storeCode?: string; limit?: number; offset?: number; }; From e34ab3fb4dbbff0d2b0cbd84da88ea627bedab22 Mon Sep 17 00:00:00 2001 From: Annisa Alexis Muljono Date: Wed, 14 Oct 2026 08:13:28 +0200 Subject: [PATCH 3/6] feat: add hierarchical category filtering for issue #37 --- backend/src/index.ts | 57 ++++++++++++++--- .../src/pages/SearchResults/SearchFilters.tsx | 54 +++++++--------- .../pages/SearchResults/SearchProductList.tsx | 32 ++++++++-- .../pages/SearchResults/SearchResultsPage.css | 61 +++++++++++++++++++ .../pages/SearchResults/SearchResultsPage.tsx | 58 +++++++++++++++++- 5 files changed, 214 insertions(+), 48 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 6dee500..31d6b13 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -123,11 +123,23 @@ const resolvers = { const search = args.search ?? null; const category = args.category ?? null; const storeCode = args.storeCode ?? null; - const limit = args.limit ?? 20; - const offset = args.offset ?? 0; + 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 = $2::integer + + UNION + + SELECT c.id + FROM categories c + INNER JOIN selected_categories sc + ON c.parent_id = sc.id + ) SELECT p.ean, p.name, @@ -136,11 +148,17 @@ const resolvers = { p.category_id AS "categoryId" FROM products p WHERE - ($1::text IS NULL OR p.name ILIKE '%' || $1 || '%') - AND - ($2::integer IS NULL OR p.category_id = $2) - AND ( + $1::text IS NULL + OR p.name ILIKE '%' || $1 || '%' + ) + AND ( + $2::integer IS NULL + OR p.category_id IN ( + SELECT id FROM selected_categories + ) + ) + AND ( $3::text IS NULL OR EXISTS ( SELECT 1 @@ -159,6 +177,7 @@ const resolvers = { return result.rows; }, + productsCount: async ( _parent: unknown, args: { @@ -173,14 +192,32 @@ const resolvers = { const result = await pool.query( ` + WITH RECURSIVE selected_categories AS ( + SELECT id + FROM categories + WHERE id = $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 - ($2::integer IS NULL OR p.category_id = $2) - AND ( + $1::text IS NULL + OR p.name ILIKE '%' || $1 || '%' + ) + AND ( + $2::integer IS NULL + OR p.category_id IN ( + SELECT id FROM selected_categories + ) + ) + AND ( $3::text IS NULL OR EXISTS ( SELECT 1 diff --git a/frontend/src/pages/SearchResults/SearchFilters.tsx b/frontend/src/pages/SearchResults/SearchFilters.tsx index f40d052..28f5852 100644 --- a/frontend/src/pages/SearchResults/SearchFilters.tsx +++ b/frontend/src/pages/SearchResults/SearchFilters.tsx @@ -2,14 +2,12 @@ type FilterOption = { label: string; value: string; - count?: number; }; const categories: FilterOption[] = [ - { label: "Ost", value: "ost" }, - { label: "Pålegg", value: "palegg" }, - { label: "Meieri", value: "meieri" }, - { label: "Frysevarer", value: "frysevarer" }, + { label: "Ost", value: "19" }, + { label: "Pålegg & frokost", value: "13" }, + { label: "Meieri & egg", value: "8" }, ]; const stores: FilterOption[] = [ @@ -23,18 +21,16 @@ const stores: FilterOption[] = [ { label: "Europris", value: "EUROPRIS_NO" }, ]; -const labels: FilterOption[] = [ - { label: "Økologisk", value: "organic" }, - { label: "Laktosefri", value: "lactose-free" }, - { label: "Norskprodusert", value: "norwegian" }, -]; - type SearchFiltersProps = { + selectedCategory: string | null; + onCategoryChange: (category: string | null) => void; selectedStore: string | null; onStoreChange: (store: string | null) => void; }; export default function SearchFilters({ + selectedCategory, + onCategoryChange, selectedStore, onStoreChange, }: SearchFiltersProps) { @@ -51,7 +47,19 @@ export default function SearchFilters({ key={category.value} > - + + onCategoryChange( + event.target.checked + ? category.value + : null, + ) + } + /> {category.label} @@ -74,7 +82,9 @@ export default function SearchFilters({ checked={selectedStore === store.value} onChange={(event) => onStoreChange( - event.target.checked ? store.value : null, + event.target.checked + ? store.value + : null, ) } /> @@ -83,24 +93,6 @@ export default function SearchFilters({ ))} - -
- - C Merking - - - {labels.map((label) => ( - - ))} -
); } diff --git a/frontend/src/pages/SearchResults/SearchProductList.tsx b/frontend/src/pages/SearchResults/SearchProductList.tsx index aeea7cf..dfbe1c1 100644 --- a/frontend/src/pages/SearchResults/SearchProductList.tsx +++ b/frontend/src/pages/SearchResults/SearchProductList.tsx @@ -74,11 +74,33 @@ export default function SearchProductList({ : "—"} - - {displayPrice - ? priceFormatter.format(displayPrice.price) - : "Pris mangler"} - +
+ + {displayPrice + ? priceFormatter.format(displayPrice.price) + : "Pris mangler"} + + + {selectedStore && displayPrice && ( + + {selectedStore === "KIWI" + ? "Kiwi" + : selectedStore === "MENY_NO" + ? "Meny" + : selectedStore === "SPAR_NO" + ? "Spar" + : selectedStore === "BUNNPRIS" + ? "Bunnpris" + : selectedStore === "COOP_NO" + ? "Coop" + : selectedStore === "JOKER_NO" + ? "Joker" + : selectedStore === "ODA_NO" + ? "Oda" + : "Europris"} + + )} +
+ )} + + {selectedStore && ( + + )} + + + + )} +
Date: Wed, 14 Oct 2026 08:20:12 +0200 Subject: [PATCH 4/6] chore: update search results preview for issue #37 --- frontend/src/search-preview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/search-preview.tsx b/frontend/src/search-preview.tsx index 5821d60..9a8576d 100644 --- a/frontend/src/search-preview.tsx +++ b/frontend/src/search-preview.tsx @@ -4,7 +4,7 @@ import { ApolloProvider } from "@apollo/client/react"; import { apolloClient } from "./apolloClient"; import SearchResultsPage from "./pages/SearchResults/SearchResultsPage"; -import "./tema.css"; +import "./theme.css"; ReactDOM.createRoot(document.getElementById("root")!).render( From 629b07146ae76b78b061a8792b6680c8954ada71 Mon Sep 17 00:00:00 2001 From: Annisa Alexis Muljono Date: Thu, 15 Oct 2026 22:15:34 +0200 Subject: [PATCH 5/6] feat(#37): mobiltilpasning, filterknapp og kategorinavn i filterbrikker --- backend/src/index.ts | 289 ++++- .../pages/SearchResults/SearchEmptyState.tsx | 144 +++ .../src/pages/SearchResults/SearchFilters.tsx | 128 +- .../pages/SearchResults/SearchProductList.tsx | 93 +- .../pages/SearchResults/SearchResultsPage.css | 1093 ++++++++++++++--- .../pages/SearchResults/SearchResultsPage.tsx | 609 +++++++-- .../pages/SearchResults/SearchSortSelect.tsx | 212 ++++ .../pages/SearchResults/searchPreferences.ts | 90 ++ .../src/pages/SearchResults/searchQueries.ts | 46 +- .../src/pages/SearchResults/searchTypes.ts | 27 +- 10 files changed, 2340 insertions(+), 391 deletions(-) create mode 100644 frontend/src/pages/SearchResults/SearchSortSelect.tsx create mode 100644 frontend/src/pages/SearchResults/searchPreferences.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 31d6b13..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,16 +36,17 @@ const typeDefs = `#graphql type Query { products( search: String - category: ID - storeCode: String + categories: [ID!] + storeCodes: [String!] + sort: String = "relevance" limit: Int = 20 offset: Int = 0 ): [Product!]! productsCount( search: String - category: ID - storeCode: String + categories: [ID!] + storeCodes: [String!] ): Int! product(ean: String!): Product @@ -47,7 +54,15 @@ const typeDefs = `#graphql categories: [Category!]! shoppingList(id: ID!): ShoppingList + categoryFilterCounts( + search: String + storeCodes: [String!] + ): [FilterCount!]! + storeFilterCounts( + search: String + categories: [ID!] + ): [FilterCount!]! } type ShoppingListItem { @@ -79,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 @@ -110,20 +243,37 @@ const resolvers = { return result.rows[0] ?? null; }, + products: async ( _parent: unknown, args: { search?: string; - category?: string; - storeCode?: string; + categories?: string[]; + storeCodes?: string[]; + sort?: string; limit?: number; offset?: number; }, ) => { const search = args.search ?? null; - const category = args.category ?? null; - const storeCode = args.storeCode ?? null; - const limit = Math.min(Math.max(args.limit ?? 20, 1), 100); + 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( @@ -131,7 +281,7 @@ const resolvers = { WITH RECURSIVE selected_categories AS ( SELECT id FROM categories - WHERE id = $2::integer + WHERE id = ANY($2::integer[]) UNION @@ -139,63 +289,108 @@ const resolvers = { FROM categories c INNER JOIN selected_categories sc ON c.parent_id = sc.id - ) - SELECT - p.ean, - p.name, - p.brand, - p.image_url AS "imageUrl", - p.category_id AS "categoryId" - FROM products p - WHERE - ( - $1::text IS NULL - OR p.name ILIKE '%' || $1 || '%' - ) - AND ( - $2::integer IS NULL - OR p.category_id IN ( - SELECT id FROM selected_categories - ) - ) - AND ( - $3::text IS NULL - OR EXISTS ( - SELECT 1 + ), + 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 pr.store_code = $3 + 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 || '%' ) - ) - ORDER BY p.name, p.ean + 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, + "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, storeCode, limit, offset], + [ + search, + categories, + storeCodes, + limit, + offset, + sort, + ], ); return result.rows; }, - productsCount: async ( _parent: unknown, args: { search?: string; - category?: string; - storeCode?: string; + categories?: string[]; + storeCodes?: string[]; }, ) => { const search = args.search ?? null; - const category = args.category ?? null; - const storeCode = args.storeCode ?? 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 = $2::integer + WHERE id = ANY($2::integer[]) UNION @@ -212,22 +407,22 @@ const resolvers = { OR p.name ILIKE '%' || $1 || '%' ) AND ( - $2::integer IS NULL + cardinality($2::integer[]) = 0 OR p.category_id IN ( SELECT id FROM selected_categories ) ) AND ( - $3::text IS NULL + cardinality($3::text[]) = 0 OR EXISTS ( SELECT 1 FROM prices pr WHERE pr.ean = p.ean - AND pr.store_code = $3 + AND pr.store_code = ANY($3::text[]) ) ) `, - [search, category, storeCode], + [search, categories, storeCodes], ); return result.rows[0].count; diff --git a/frontend/src/pages/SearchResults/SearchEmptyState.tsx b/frontend/src/pages/SearchResults/SearchEmptyState.tsx index e69de29..bf5510d 100644 --- a/frontend/src/pages/SearchResults/SearchEmptyState.tsx +++ b/frontend/src/pages/SearchResults/SearchEmptyState.tsx @@ -0,0 +1,144 @@ + +import type { FilterCount } from "./searchTypes"; + +type SearchEmptyStateProps = { + searchTerm: string; + hasActiveFilters: boolean; + selectedStores: string[]; + alternativeStores: FilterCount[]; + onSelectStore: (storeCode: string) => void; + onResetFilters: () => void; +}; + +const storeNames: Record = { + KIWI: "Kiwi", + MENY_NO: "Meny", + SPAR_NO: "Spar", + BUNNPRIS: "Bunnpris", + COOP_NO: "Coop", + JOKER_NO: "Joker", + ODA_NO: "Oda", + EUROPRIS_NO: "Europris", + ENGROSSNETT_NO: "Engrosnett", + HAVARISTEN: "Havaristen", +}; + +export default function SearchEmptyState({ + searchTerm, + hasActiveFilters, + selectedStores, + alternativeStores, + onSelectStore, + onResetFilters, +}: SearchEmptyStateProps) { + const selectedStoreNames = selectedStores.map( + (code) => storeNames[code] ?? code, + ); + + const storeDescription = + selectedStoreNames.length === 1 + ? ` hos ${selectedStoreNames[0]}` + : selectedStoreNames.length > 1 + ? ` hos de ${selectedStoreNames.length} valgte butikkene` + : ""; + + const visibleAlternatives = alternativeStores + .filter( + (store) => + !selectedStores.includes(store.value) && + store.count > 0, + ) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + + return ( +
+ + +

+ INGEN TREFF +

+ +

+ Ingen produkter funnet +

+ +

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

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

+ Prøv en annen butikkjede +

+ +

+ Søket gir treff hos disse butikkene: +

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

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

+ )} +
+ ); +} diff --git a/frontend/src/pages/SearchResults/SearchFilters.tsx b/frontend/src/pages/SearchResults/SearchFilters.tsx index 28f5852..f7eb772 100644 --- a/frontend/src/pages/SearchResults/SearchFilters.tsx +++ b/frontend/src/pages/SearchResults/SearchFilters.tsx @@ -1,47 +1,73 @@ -type FilterOption = { - label: string; - value: string; -}; - -const categories: FilterOption[] = [ - { label: "Ost", value: "19" }, - { label: "Pålegg & frokost", value: "13" }, - { label: "Meieri & egg", value: "8" }, -]; - -const stores: FilterOption[] = [ - { label: "Kiwi", value: "KIWI" }, - { label: "Meny", value: "MENY_NO" }, - { label: "Spar", value: "SPAR_NO" }, - { label: "Bunnpris", value: "BUNNPRIS" }, - { label: "Coop", value: "COOP_NO" }, - { label: "Joker", value: "JOKER_NO" }, - { label: "Oda", value: "ODA_NO" }, - { label: "Europris", value: "EUROPRIS_NO" }, -]; +import type { FilterCount } from "./searchTypes"; type SearchFiltersProps = { - selectedCategory: string | null; - onCategoryChange: (category: string | null) => void; - selectedStore: string | null; - onStoreChange: (store: string | null) => void; + 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({ - selectedCategory, - onCategoryChange, - selectedStore, - onStoreChange, + 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 (
- A Kategori + KATEGORI - {categories.map((category) => ( + {sortedCategories.map((category) => ( ))}
- B Butikkjede + BUTIKKJEDE - {stores.map((store) => ( + {sortedStores.map((store) => ( ))} diff --git a/frontend/src/pages/SearchResults/SearchProductList.tsx b/frontend/src/pages/SearchResults/SearchProductList.tsx index dfbe1c1..5814869 100644 --- a/frontend/src/pages/SearchResults/SearchProductList.tsx +++ b/frontend/src/pages/SearchResults/SearchProductList.tsx @@ -1,44 +1,65 @@ -import type { SearchProduct, ProductPrice } from "./searchTypes"; +import type { + SearchProduct, + ProductPrice, +} from "./searchTypes"; type SearchProductListProps = { products: SearchProduct[]; - selectedStore?: string | null; + selectedStores?: string[]; + startIndex?: number; onAddProduct?: (product: SearchProduct) => void; }; -const priceFormatter = new Intl.NumberFormat("nb-NO", { +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[], - selectedStore?: string | null, + selectedStores: string[], ): ProductPrice | null { - if (selectedStore) { - return ( - prices.find((price) => price.storeCode === selectedStore) ?? null - ); - } + const availablePrices = + selectedStores.length === 0 + ? prices + : prices.filter((price) => + selectedStores.includes(price.storeCode), + ); - if (prices.length === 0) { + if (availablePrices.length === 0) { return null; } - return prices.reduce((lowest, current) => + return availablePrices.reduce((lowest, current) => current.price < lowest.price ? current : lowest, ); } export default function SearchProductList({ products, - selectedStore, + selectedStores = [], + startIndex = 0, onAddProduct, }: SearchProductListProps) { return (
-