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/src/pages/SearchResults/SearchEmptyState.tsx b/frontend/src/pages/SearchResults/SearchEmptyState.tsx new file mode 100644 index 0000000..bf5510d --- /dev/null +++ b/frontend/src/pages/SearchResults/SearchEmptyState.tsx @@ -0,0 +1,144 @@ + +import type { FilterCount } from "./searchTypes"; + +type SearchEmptyStateProps = { + searchTerm: string; + hasActiveFilters: boolean; + selectedStores: string[]; + alternativeStores: FilterCount[]; + onSelectStore: (storeCode: string) => void; + onResetFilters: () => void; +}; + +const storeNames: Record = { + KIWI: "Kiwi", + MENY_NO: "Meny", + SPAR_NO: "Spar", + BUNNPRIS: "Bunnpris", + COOP_NO: "Coop", + JOKER_NO: "Joker", + ODA_NO: "Oda", + EUROPRIS_NO: "Europris", + ENGROSSNETT_NO: "Engrosnett", + HAVARISTEN: "Havaristen", +}; + +export default function SearchEmptyState({ + searchTerm, + hasActiveFilters, + selectedStores, + alternativeStores, + onSelectStore, + onResetFilters, +}: SearchEmptyStateProps) { + const selectedStoreNames = selectedStores.map( + (code) => storeNames[code] ?? code, + ); + + const storeDescription = + selectedStoreNames.length === 1 + ? ` hos ${selectedStoreNames[0]}` + : selectedStoreNames.length > 1 + ? ` hos de ${selectedStoreNames.length} valgte butikkene` + : ""; + + const visibleAlternatives = alternativeStores + .filter( + (store) => + !selectedStores.includes(store.value) && + store.count > 0, + ) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + + return ( +
+ + +

+ INGEN TREFF +

+ +

+ Ingen produkter funnet +

+ +

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

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

+ Prøv en annen butikkjede +

+ +

+ Søket gir treff hos disse butikkene: +

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

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

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