diff --git a/README.md b/README.md index 1b207e9..9d6f133 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ Repository for team IT2810-H26-T27 i IT2810-H26. -> **Status: Andre underveisinnlevering.** Backend med GraphQL (Apollo Server) og PostgreSQL er satt opp, og varer fra Kassalapp importeres til databasen. Frontend henter og viser varer via Apollo Client. Handlelister kan opprettes og fylles med varer gjennom API-et. Se [Kjøre prosjektet lokalt](#kjøre-prosjektet-lokalt) for oppsett. +> **Status: Andre underveisinnlevering.** Backend med GraphQL (Apollo Server) og PostgreSQL er satt opp, og varer fra Kassalapp importeres til databasen. Frontend henter og viser varer via Apollo Client. Handlelister kan opprettes og fylles med varer gjennom API-et. Se [Kjøre prosjektet lokalt](#kjøre-prosjektet-lokalt) for oppsett. Vi har allerede satt det opp på VM så hvis dere ikke vil kjøre det lokalt kan dere klikke på linken under. + +Link til nettsiden: http://it2810-27.idi.ntnu.no/project2/ ## Idé @@ -35,46 +37,33 @@ Handlelappen er en digital handleliste. Du søker opp dagligvarer eller blar i k ## Design (første utkast) -Designet er laget i Figma. Disse skjermbildene er et første utkast og kan endre seg. +Designet er laget i Figma. Disse skjermbildene er et første utkast og kan endre seg. Vi legger til link til figma. +Link til figma https://www.figma.com/design/cjDVbDbednm8qHobizzFji/Handlelappen-%E2%80%93-notatbok--Copy-?node-id=2-2&m=dev -### Hjem og søk +Under er beskrivelse av flyten: +### Hjem og søk Forsiden har søkefelt, kategorier og den aktive lappen. På søkesiden kan man filtrere på kategori, pris og merking (økologisk, laktosefri, norskprodusert) og sortere resultatene. Varer legges rett på lappen fra resultatlista, og lappen vises ved siden av. -![Hjem og søk](docs/design/hjem-og-sok.png) - #### Søk og filtrering - Hva du får opp når du filtrerer på en bestemt butikk hvis den har produktet og hvis den ikke har produktet. -![Søk og filtrering](docs/design/sok-filtrert-pa-butikk.png) ### Produktside og meny - Produktsiden viser pris, kilopris, ingredienser, allergener og næringsinnhold. Her kan man endre antall og legge til et notat, for eksempel «skivet, hvis de har». Menyen gir tilgang til alle delene av appen. -![Produktside og meny](docs/design/produkt-og-meny.png) - ### Mine handlelister og aktiv liste - Man kan ha flere aktive lister, og den man bruker nå er merket. På den aktive lista kan man endre antall, fjerne varer, sortere etter butikkavdeling og se estimert sum. -![Handlelister](docs/design/handlelister.png) - ### Tidligere handlelister - Ferdige lister er gruppert etter måned. Man kan søke i dem og laste inn eldre lister. Fra en tidligere liste kan man bruke den på nytt eller legge varene på den aktive lappen. -![Tidligere lister](docs/design/tidligere-lister.png) ### Handletur (mobil) - Handleturen er laget for mobil, siden den brukes i butikken. Varene er gruppert etter avdeling, og en fremdriftslinje viser hvor mange som er krysset av. -![Handletur på mobil](docs/design/handletur-mobil.png) ## Data - **Kilde:** [Kassalapp API](https://kassal.app/api), et API med norske dagligvarer og priser fra de fleste kjedene, blant annet Rema 1000, Kiwi, Meny, Coop, Joker, Spar og Bunnpris. Feltene vi har tenkt å bruke: 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/App.css b/frontend/src/App.css index 1223237..b9490c2 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2,9 +2,18 @@ Farger og skrift kommer fra styles/theme.css. */ #root { - max-width: 1100px; + width: 100%; + min-height: 100vh; + margin: 0; + padding: 0; +} + +main { + width: 100%; + max-width: 1400px; margin: 0 auto; - padding: 48px 24px; + padding: 32px; + box-sizing: border-box; } /* Søkefelt + knapp */ 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 + + + +

+ 12 400 VARER · 6 KJEDER · OPPDATERT DAGLIG +

+ + + +
+
+
+

+ 01 + Kategorier +

+

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

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

HANDLELAPPEN — IT2810 · GRUPPE 27

+

SIDE 01 / 11

+
+ + ); +} + +export default HomePage; diff --git a/frontend/src/productDetails/ProductDetailsPage.css b/frontend/src/productDetails/ProductDetailsPage.css new file mode 100644 index 0000000..a24eb10 --- /dev/null +++ b/frontend/src/productDetails/ProductDetailsPage.css @@ -0,0 +1,527 @@ + +/* ===== PRODUKTDETALJSIDE ===== */ + +.product-details-page { + width: 100%; + max-width: 1100px; + margin: 0 auto; + padding: 48px 24px 80px; + box-sizing: border-box; + color: #171717; + text-align: left; +} + +.product-details-page *, +.product-details-page *::before, +.product-details-page *::after { + box-sizing: border-box; +} + +.product-details-page h1, +.product-details-page h2, +.product-details-page p, +.product-details-page figure, +.product-details-page ol, +.product-details-page dl { + margin: 0; +} + +.product-details-page h1, +.product-details-page h2 { + color: #171717; + text-align: left; +} + +/* ===== TILBAKE-LENKE ===== */ + +.product-details-page .product-back-navigation { + margin-bottom: 26px; +} + +.product-details-page .product-back-navigation a { + color: #d3045d; + text-decoration: none; + font-size: 11px; + font-weight: 600; + letter-spacing: 1px; +} + +.product-details-page .product-back-navigation a:hover { + text-decoration: underline; +} + +/* ===== HOVEDLAYOUT ===== */ + +.product-details-page .product-details-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 52px; + align-items: start; +} + +.product-details-page .product-details-left, +.product-details-page .product-details-right { + min-width: 0; + text-align: left; +} + +.product-details-page .product-details-left { + display: flex; + flex-direction: column; + gap: 30px; +} + +/* ===== PRODUKTBILDE ===== */ + +.product-details-page .product-figure { + margin: 0; +} + +.product-details-page .product-image { + width: 100%; + aspect-ratio: 1.2 / 1; + border: 1px solid #780b2b; + border-radius: 10px; + + display: flex; + justify-content: center; + align-items: center; + + background-color: #fafafa; + background-image: + linear-gradient(#e8e8e8 1px, transparent 1px), + linear-gradient(90deg, #e8e8e8 1px, transparent 1px); + background-size: 20px 20px; +} + +.product-details-page .product-image p { + color: #777; + font-size: 12px; + letter-spacing: 1px; +} + +.product-details-page .product-figure figcaption { + margin-top: 12px; + color: #777; + font-size: 10px; + letter-spacing: 1px; +} + +/* ===== INGREDIENSER OG ALLERGENER ===== */ + +.product-details-page .product-ingredients h2, +.product-details-page .product-allergens h2, +.product-details-page .product-nutrition h2 { + padding-bottom: 12px; + margin-bottom: 14px; + border-bottom: 1px solid #ddd; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; +} + +.product-details-page .section-letter { + color: #d3045d; + margin-right: 5px; +} + +.product-details-page .product-ingredients p { + font-size: 14px; + line-height: 1.7; + color: #555; +} + +.product-details-page .product-allergens p { + display: inline-block; + padding: 7px 10px; + border-radius: 4px; + background: #fce8ef; + color: #780b2b; + font-size: 11px; + font-weight: 600; +} + +/* ===== PRODUKTINFORMASJON ===== */ + +.product-details-page .product-info { + margin-bottom: 28px; +} + +.product-details-page .product-brand { + color: #d3045d; + font-size: 11px; + font-weight: 600; + letter-spacing: 1px; + margin-bottom: 8px; +} + +.product-details-page .product-name { + color: #171717; + font-size: 42px; + font-weight: 600; + line-height: 1.12; + letter-spacing: -1px; +} + +.product-details-page .product-category { + margin-top: 10px; + color: #777; + font-size: 11px; + letter-spacing: 1px; +} + +/* Prisområde */ + +.product-details-page .product-price-container { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 16px; + margin-top: 30px; + margin-bottom: 28px; +} + +.product-details-page .product-price { + display: inline-flex; + align-items: baseline; + gap: 5px; + margin: 0; + white-space: nowrap; + line-height: 1; +} + +.product-details-page .price-number { + font-size: 42px; + font-weight: 500; + color: #171717; +} + +.product-details-page .price-currency { + font-family: "Brush Script MT", "Apple Chancery", cursive; + font-size: 60px; /* Større */ + font-weight: 300; /* Tynnere */ + font-style: italic; + color: #780b2b; + line-height: 1; +} + +.product-details-page .product-cheapest-store { + margin: 0; + color: #777; + font-family: monospace; + font-size: 9px; + letter-spacing: 0.3px; +} + +.product-details-page .product-cheapest-store { + margin-top: 8px; + color: #777; + font-size: 10px; + letter-spacing: 0.5px; +} + +/* ===== PRISSAMMENLIGNING ===== */ + +.product-details-page .store-prices { + border: 1px solid #780b2b; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 3px 0 rgba(120, 11, 43, 0.12); +} + +.product-details-page .store-prices-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; +} + +.product-details-page .store-prices-header h2 { + margin: 0; + padding: 0; + border: none; + font-size: 11px; + font-weight: 600; + letter-spacing: 1px; +} + +.product-details-page .store-prices-header span { + color: #777; + font-size: 10px; + white-space: nowrap; +} + +.product-details-page .store-prices-list { + margin: 0; + padding: 0; + list-style: none; +} + +.product-details-page .store-price-row { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) 88px 90px; + align-items: center; + gap: 8px; + min-height: 46px; + padding: 12px 16px; + border-top: 1px solid #eee; +} + +.product-details-page .store-price-row.cheapest { + background: #fce8ef; +} + +.product-details-page .store-number { + color: #d3045d; + font-size: 10px; + font-weight: 700; +} + +.product-details-page .store-name { + color: #171717; + font-size: 13px; + font-weight: 500; +} + +.product-details-page .store-difference { + color: #888; + font-size: 10px; + text-align: right; + white-space: nowrap; +} + +.product-details-page .cheapest .store-difference { + justify-self: end; + padding: 5px 7px; + border-radius: 3px; + background: #d3045d; + color: white; + font-size: 9px; + font-weight: 700; +} + +.product-details-page .store-price { + color: #171717; + font-size: 13px; + font-weight: 600; + text-align: right; + white-space: nowrap; +} + +/* ===== HANDLELISTE ===== */ +.product-details-page .shopping-list-controls { + position: relative; + display: grid; + grid-template-columns: auto 1fr; + column-gap: 12px; + align-items: center; + + margin-top: 38px; + padding: 26px 26px 24px; + + border: 1px solid #171717; + border-left: 3px solid #d3045d; + background: white; + box-shadow: 0 12px 18px rgba(0, 0, 0, 0.08); +} + +.product-details-page .shopping-list-controls::before { + content: ""; + position: absolute; + width: 68px; + height: 16px; + top: -9px; + left: 50%; + transform: translateX(-50%) rotate(2deg); + background: #f6cada; +} + +.product-details-page .shopping-list-header { + grid-column: 1 / -1; +} + + +.product-details-page .shopping-list-header h2 { + color: #780b2b; + font-style: italic; + font-weight: 600; + line-height: 1.3; + font-size: 21px; +} + +.product-details-page .quantity-controls { + display: inline-flex; + align-items: center; + margin-top: 14px; + border: 1px solid #e5e5e5; + border-radius: 5px; + overflow: hidden; + grid-column: 1; + margin-top: 12px; +} + +.product-details-page .quantity-controls button { + min-width: 32px; + min-height: 32px; + padding: 4px; + background: white; + border: none; + color: #171717; + cursor: pointer; +} + +.product-details-page .quantity-controls button:hover:not(:disabled) { + background: #fce8ef; +} + +.product-details-page .quantity-controls button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.product-details-page .quantity-controls output { + min-width: 24px; + text-align: center; + font-size: 12px; + color: #171717; +} + +.product-details-page .remove-product { + grid-column: 2; + justify-self: start; + + margin: 12px 0 0; + padding: 9px 12px; + + border: 1px solid #ddd; + border-radius: 5px; + background: white; + color: #171717; + + font-size: 11px; + cursor: pointer; +} + +.product-details-page .remove-product:hover { + background: #fce8ef; +} + +.product-details-page .shopping-list-controls label { + display: block; + margin-top: 24px; + margin-bottom: 10px; + color: #777; + font-size: 10px; + letter-spacing: 1px; +} + +.product-details-page .shopping-list-controls textarea { + display: block; + width: 100%; + min-height: 42px; + padding: 8px 0; + border: none; + border-bottom: 1px solid #f3b4cc; + border-radius: 0; + background: transparent; + color: #171717; + font: inherit; + font-size: 14px; + resize: vertical; + min-height: 40px; + height: 45px; +} + +.product-details-page .shopping-list-controls textarea::placeholder { + color: #888; + font-style: italic; +} + +.product-details-page .shopping-list-controls label, +.product-details-page .shopping-list-controls textarea { + grid-column: 1 / -1; +} + +.product-details-page button:focus-visible, +.product-details-page textarea:focus-visible, +.product-details-page a:focus-visible { + outline: 2px solid #780b2b; + outline-offset: 3px; +} + +/* ===== NÆRINGSINNHOLD ===== */ + +.product-details-page .product-nutrition { + margin-top: 38px; +} + +.product-details-page .product-nutrition dl { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + column-gap: 16px; + row-gap: 10px; + margin: 0; + font-size: 12px; +} + +.product-details-page .product-nutrition dt { + color: #666; +} + +.product-details-page .product-nutrition dd { + margin: 0; + color: #171717; + font-weight: 600; + text-align: right; +} + +.product-details-page .nutrition-subitem { + padding-left: 12px; + color: #999 !important; +} + +/* ===== RESPONSIVT DESIGN ===== */ + +@media (max-width: 768px) { + .product-details-page { + padding: 28px 16px 60px; + } + + .product-details-page .product-details-layout { + grid-template-columns: 1fr; + gap: 32px; + } + + .product-details-page .product-name { + font-size: 34px; + } + + .product-details-page .product-price { + font-size: 32px; + } +} + +@media (max-width: 480px) { + .product-details-page .store-price-row { + grid-template-columns: 20px minmax(0, 1fr) auto; + gap: 6px; + padding: 12px; + } + + .product-details-page .store-difference { + grid-column: 2; + grid-row: 2; + text-align: left; + justify-self: start; + } + + .product-details-page .store-price { + grid-column: 3; + grid-row: 1 / 3; + } + + .product-details-page .shopping-list-controls { + padding: 20px 16px; + } +} diff --git a/frontend/src/productDetails/ProductDetailsPage.tsx b/frontend/src/productDetails/ProductDetailsPage.tsx new file mode 100644 index 0000000..aace43a --- /dev/null +++ b/frontend/src/productDetails/ProductDetailsPage.tsx @@ -0,0 +1,191 @@ +import { useState } from "react"; +import "./ProductDetailsPage.css"; + +type StorePrice = { + store: string; + price: number; +}; + +const storePrices: StorePrice[] = [ + { store: "Kiwi", price: 79.9 }, + { store: "Rema 1000", price: 81.4 }, + { store: "Extra", price: 84.9 }, + { store: "Meny", price: 92.9 }, + { store: "Spar", price: 94.5 }, +]; + +export default function ProductDetailsPage() { + const [quantity, setQuantity] = useState(1); + const [isInList, setIsInList] = useState(true); + const [note, setNote] = useState(""); + return ( +
+
+ + {/* Venstre side: Produktbilde */} +
+
+
+

Mangler bilde

+
+ +
+ FIG. 5 — NORVEGIA 26 %, 500 G +
+
+ +
+

A — INGREDIENSER

+

Pasteurisert kumelk, salt, syrekultur, løpe.

+
+ +
+

B — ALLERGENER

+

MELK

+
+ + +
+ + {/* Høyre side: Produktinformasjon */} +
+

TINE

+ +

Norvegia 26 %

+ +

500 G · OST

+ +
+

+ 79,90 + kr +

+ +

+ BILLIGST HOS KIWI · 159,80 KR/KG +

+
+
+
+

PRIS I BUTIKKENE

+ {storePrices.length} KJEDER +
+ +
    + {[...storePrices] + .sort((a, b) => a.price - b.price) + .map((item, index, sortedPrices) => { + const cheapestPrice = sortedPrices[0].price; + const difference = item.price - cheapestPrice; + + return ( +
  1. + + {String(index + 1).padStart(2, "0")} + + + {item.store} + + + {index === 0 + ? "BILLIGST" + : `+${difference.toFixed(2).replace(".", ",")} KR`} + + + + {item.price.toFixed(2).replace(".", ",")} kr + +
  2. + ); + })} +
+
+
+ +
+

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

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