From 8a963729e1bc6c1329325a9c40374adab7d7b656 Mon Sep 17 00:00:00 2001 From: Tara Aqeel Date: Fri, 16 Oct 2026 11:46:27 +0200 Subject: [PATCH 1/6] refactor: move product details page into pages directory --- frontend/src/{ => pages}/productDetails/ProductDetailsPage.css | 0 frontend/src/{ => pages}/productDetails/ProductDetailsPage.tsx | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename frontend/src/{ => pages}/productDetails/ProductDetailsPage.css (100%) rename frontend/src/{ => pages}/productDetails/ProductDetailsPage.tsx (100%) diff --git a/frontend/src/productDetails/ProductDetailsPage.css b/frontend/src/pages/productDetails/ProductDetailsPage.css similarity index 100% rename from frontend/src/productDetails/ProductDetailsPage.css rename to frontend/src/pages/productDetails/ProductDetailsPage.css diff --git a/frontend/src/productDetails/ProductDetailsPage.tsx b/frontend/src/pages/productDetails/ProductDetailsPage.tsx similarity index 100% rename from frontend/src/productDetails/ProductDetailsPage.tsx rename to frontend/src/pages/productDetails/ProductDetailsPage.tsx From 51f1ac769a7367e61decdfd47a3877de50a567cd Mon Sep 17 00:00:00 2001 From: Tara Aqeel Date: Fri, 16 Oct 2026 12:57:34 +0200 Subject: [PATCH 2/6] feat: connect product details to GraphQL --- .../productDetails/ProductDetailsPage.css | 6 + .../productDetails/ProductDetailsPage.tsx | 154 +++++++++++------- .../pages/productDetails/productQueries.ts | 51 ++++++ 3 files changed, 148 insertions(+), 63 deletions(-) create mode 100644 frontend/src/pages/productDetails/productQueries.ts diff --git a/frontend/src/pages/productDetails/ProductDetailsPage.css b/frontend/src/pages/productDetails/ProductDetailsPage.css index a24eb10..2d57f64 100644 --- a/frontend/src/pages/productDetails/ProductDetailsPage.css +++ b/frontend/src/pages/productDetails/ProductDetailsPage.css @@ -107,6 +107,12 @@ letter-spacing: 1px; } +.product-image-content { + width: 100%; + height: 100%; + object-fit: contain; +} + /* ===== INGREDIENSER OG ALLERGENER ===== */ .product-details-page .product-ingredients h2, diff --git a/frontend/src/pages/productDetails/ProductDetailsPage.tsx b/frontend/src/pages/productDetails/ProductDetailsPage.tsx index aace43a..b426228 100644 --- a/frontend/src/pages/productDetails/ProductDetailsPage.tsx +++ b/frontend/src/pages/productDetails/ProductDetailsPage.tsx @@ -1,47 +1,85 @@ import { useState } from "react"; +import { useQuery } from "@apollo/client/react"; +import { PRODUCT_QUERY } from "./productQueries"; import "./ProductDetailsPage.css"; -type StorePrice = { - store: string; - price: number; +type ProductDetailsPageProps = { + ean: string; }; -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({ + ean, +}: ProductDetailsPageProps) { + const { data, loading, error } = useQuery(PRODUCT_QUERY, { + variables: { ean }, + }); -export default function ProductDetailsPage() { + const product = data?.product; const [quantity, setQuantity] = useState(1); const [isInList, setIsInList] = useState(true); const [note, setNote] = useState(""); + + if (loading) { + return
Laster produkt...
; + } + + if (error) { + return ( +
+

Kunne ikke hente produktet: {error.message}

+
+ ); + } + + if (!product) { + return ( +
+

Fant ikke produktet.

+
+ ); + } + + const storePrices = [...product.prices].sort( + (a, b) => a.price - b.price + ); + + const cheapest = storePrices[0]; + + const formatPrice = (price: number) => + + price.toFixed(2).replace(".", ","); return (
{/* Venstre side: Produktbilde */}
-
-
-

Mangler bilde

-
+
+
+ {product.imageUrl ? ( + {product.name} + ) : ( +

Mangler bilde

+ )} +
-
- FIG. 5 — NORVEGIA 26 %, 500 G -
+
+ FIG. 5 — {product.name.toUpperCase()} +

A — INGREDIENSER

-

Pasteurisert kumelk, salt, syrekultur, løpe.

+

Ingrediensinformasjon er ikke tilgjengelig.

B — ALLERGENER

-

MELK

+

Allergeninformasjon er ikke tilgjengelig.

@@ -49,22 +87,34 @@ export default function ProductDetailsPage() { {/* Høyre side: Produktinformasjon */}
-

TINE

- -

Norvegia 26 %

+ {product.brand && ( +

{product.brand}

+ )} -

500 G · OST

+

{product.name}

-
-

- 79,90 - kr -

+ {product.category && ( +

{product.category.name}

+ )} -

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

-
+
+ {cheapest ? ( + <> +

+ + {formatPrice(cheapest.price)} + + kr +

+ +

+ BILLIGST HOS {cheapest.storeCode.toUpperCase()} +

+ + ) : ( +

Ingen priser tilgjengelig.

+ )} +

PRIS I BUTIKKENE

@@ -72,27 +122,24 @@ export default function ProductDetailsPage() {
    - {[...storePrices] - .sort((a, b) => a.price - b.price) - .map((item, index, sortedPrices) => { - const cheapestPrice = sortedPrices[0].price; - const difference = item.price - cheapestPrice; + {storePrices.map((item, index) => { + const difference = item.price - (cheapest?.price ?? item.price); return (
  1. {String(index + 1).padStart(2, "0")} - {item.store} + {item.storeCode} {index === 0 ? "BILLIGST" - : `+${difference.toFixed(2).replace(".", ",")} KR`} + : `+${formatPrice(difference)} KR`} @@ -162,28 +209,9 @@ export default function ProductDetailsPage() {
-

C — NÆRINGSINNHOLD PER 100 G

- -
-
Energi
-
1 469 kJ / 353 kcal
- -
Fett
-
26 g
- -
Hvorav mettede fettsyrer
-
17 g
- -
Karbohydrater
-
0 g
- -
Protein
-
27 g
- -
Salt
-
1,2 g
-
-
+

C — NÆRINGSINNHOLD

+

Næringsinnhold er ikke tilgjengelig.

+
diff --git a/frontend/src/pages/productDetails/productQueries.ts b/frontend/src/pages/productDetails/productQueries.ts new file mode 100644 index 0000000..83fca79 --- /dev/null +++ b/frontend/src/pages/productDetails/productQueries.ts @@ -0,0 +1,51 @@ +import { gql } from "@apollo/client"; +import type { TypedDocumentNode } from "@apollo/client"; + +export type Product = { + ean: string; + name: string; + brand: string | null; + imageUrl: string | null; + category: { + id: string; + name: string; + } | null; + prices: { + storeCode: string; + price: number; + unitPrice: number | null; + url: string | null; + }[]; +}; + +type ProductQueryData = { + product: Product | null; +}; + +type ProductQueryVariables = { + ean: string; +}; + +export const PRODUCT_QUERY: TypedDocumentNode< + ProductQueryData, + ProductQueryVariables +> = gql` + query Product($ean: String!) { + product(ean: $ean) { + ean + name + brand + imageUrl + category { + id + name + } + prices { + storeCode + price + unitPrice + url + } + } + } +`; \ No newline at end of file From 2a6694a38d334498c7e6cd458dc8be97823d58ea Mon Sep 17 00:00:00 2001 From: Tara Aqeel Date: Fri, 16 Oct 2026 15:29:19 +0200 Subject: [PATCH 3/6] style: support dark mode on product details page --- .../productDetails/ProductDetailsPage.css | 112 ++++++++++-------- 1 file changed, 65 insertions(+), 47 deletions(-) diff --git a/frontend/src/pages/productDetails/ProductDetailsPage.css b/frontend/src/pages/productDetails/ProductDetailsPage.css index 2d57f64..6cdf126 100644 --- a/frontend/src/pages/productDetails/ProductDetailsPage.css +++ b/frontend/src/pages/productDetails/ProductDetailsPage.css @@ -2,13 +2,15 @@ /* ===== PRODUKTDETALJSIDE ===== */ .product-details-page { - width: 100%; + width: 100%; max-width: 1100px; margin: 0 auto; padding: 48px 24px 80px; box-sizing: border-box; - color: #171717; + background-color: var(--bg-surface); + color: var(--text-primary); text-align: left; + color-scheme: light; } .product-details-page *, @@ -28,7 +30,7 @@ .product-details-page h1, .product-details-page h2 { - color: #171717; + color: var(--text-primary); text-align: left; } @@ -39,7 +41,7 @@ } .product-details-page .product-back-navigation a { - color: #d3045d; + color: var(--text-accent); text-decoration: none; font-size: 11px; font-weight: 600; @@ -95,19 +97,35 @@ } .product-details-page .product-image p { - color: #777; + color: var(--text-muted); font-size: 12px; letter-spacing: 1px; } .product-details-page .product-figure figcaption { margin-top: 12px; - color: #777; + color: var(--text-muted); font-size: 10px; letter-spacing: 1px; } -.product-image-content { +/* Produkt med bilde */ +.product-details-page .product-image.has-image { + background: transparent; + border: none; +} + +/* Produkt uten bilde */ +.product-details-page .product-image.no-image { + background-color: #fafafa; + background-image: + linear-gradient(#e8e8e8 1px, transparent 1px), + linear-gradient(90deg, #e8e8e8 1px, transparent 1px); + background-size: 20px 20px; + border: 1px solid #780b2b; +} + +.product-details-page .product-image-content { width: 100%; height: 100%; object-fit: contain; @@ -120,29 +138,29 @@ .product-details-page .product-nutrition h2 { padding-bottom: 12px; margin-bottom: 14px; - border-bottom: 1px solid #ddd; + border-bottom: 1px solid var(--border-brand); font-size: 11px; font-weight: 600; letter-spacing: 0.5px; } .product-details-page .section-letter { - color: #d3045d; + color: var(--text-accent); margin-right: 5px; } .product-details-page .product-ingredients p { font-size: 14px; line-height: 1.7; - color: #555; + color: var(--text-secondary); } .product-details-page .product-allergens p { display: inline-block; padding: 7px 10px; border-radius: 4px; - background: #fce8ef; - color: #780b2b; + background: var(--bg-tint); + color: var(--text-primary); font-size: 11px; font-weight: 600; } @@ -154,7 +172,7 @@ } .product-details-page .product-brand { - color: #d3045d; + color: var(--text-accent); font-size: 11px; font-weight: 600; letter-spacing: 1px; @@ -162,7 +180,7 @@ } .product-details-page .product-name { - color: #171717; + color: var(--text-primary); font-size: 42px; font-weight: 600; line-height: 1.12; @@ -171,7 +189,7 @@ .product-details-page .product-category { margin-top: 10px; - color: #777; + color: var(--text-muted); font-size: 11px; letter-spacing: 1px; } @@ -199,7 +217,7 @@ .product-details-page .price-number { font-size: 42px; font-weight: 500; - color: #171717; + color: var(--text-primary); } .product-details-page .price-currency { @@ -207,13 +225,13 @@ font-size: 60px; /* Større */ font-weight: 300; /* Tynnere */ font-style: italic; - color: #780b2b; + color: var(--text-accent); line-height: 1; } .product-details-page .product-cheapest-store { margin: 0; - color: #777; + color: var(--text-muted); font-family: monospace; font-size: 9px; letter-spacing: 0.3px; @@ -221,7 +239,7 @@ .product-details-page .product-cheapest-store { margin-top: 8px; - color: #777; + color: var(--text-muted); font-size: 10px; letter-spacing: 0.5px; } @@ -229,7 +247,7 @@ /* ===== PRISSAMMENLIGNING ===== */ .product-details-page .store-prices { - border: 1px solid #780b2b; + border: 1px solid var(--border-brand); border-radius: 10px; overflow: hidden; box-shadow: 0 3px 0 rgba(120, 11, 43, 0.12); @@ -253,7 +271,7 @@ } .product-details-page .store-prices-header span { - color: #777; + color: var(--text-muted); font-size: 10px; white-space: nowrap; } @@ -271,27 +289,27 @@ gap: 8px; min-height: 46px; padding: 12px 16px; - border-top: 1px solid #eee; + border-top: 1px solid var(--border-brand); } .product-details-page .store-price-row.cheapest { - background: #fce8ef; + background: var(--bg-tint); } .product-details-page .store-number { - color: #d3045d; + color: var(--text-accent); font-size: 10px; font-weight: 700; } .product-details-page .store-name { - color: #171717; + color: var(--text-primary); font-size: 13px; font-weight: 500; } .product-details-page .store-difference { - color: #888; + color: var(--text-muted); font-size: 10px; text-align: right; white-space: nowrap; @@ -301,14 +319,14 @@ justify-self: end; padding: 5px 7px; border-radius: 3px; - background: #d3045d; + background: var(--text-accent); color: white; font-size: 9px; font-weight: 700; } .product-details-page .store-price { - color: #171717; + color: var(--text-primary); font-size: 13px; font-weight: 600; text-align: right; @@ -326,9 +344,9 @@ margin-top: 38px; padding: 26px 26px 24px; - border: 1px solid #171717; - border-left: 3px solid #d3045d; - background: white; + border: 1px solid var(--text-primary); + border-left: 3px solid var(--text-accent); + background: var(--bg-surface); box-shadow: 0 12px 18px rgba(0, 0, 0, 0.08); } @@ -340,7 +358,7 @@ top: -9px; left: 50%; transform: translateX(-50%) rotate(2deg); - background: #f6cada; + background: var(--note-tape); } .product-details-page .shopping-list-header { @@ -349,7 +367,7 @@ .product-details-page .shopping-list-header h2 { - color: #780b2b; + color: var(--text-accent); font-style: italic; font-weight: 600; line-height: 1.3; @@ -360,7 +378,7 @@ display: inline-flex; align-items: center; margin-top: 14px; - border: 1px solid #e5e5e5; + border: 1px solid var(--border-hairline); border-radius: 5px; overflow: hidden; grid-column: 1; @@ -371,14 +389,14 @@ min-width: 32px; min-height: 32px; padding: 4px; - background: white; + background: var(--bg-surface); border: none; - color: #171717; + color: var(--text-primary); cursor: pointer; } .product-details-page .quantity-controls button:hover:not(:disabled) { - background: #fce8ef; + background: var(--bg-tint); } .product-details-page .quantity-controls button:disabled { @@ -390,7 +408,7 @@ min-width: 24px; text-align: center; font-size: 12px; - color: #171717; + color: var(--text-primary); } .product-details-page .remove-product { @@ -400,24 +418,24 @@ margin: 12px 0 0; padding: 9px 12px; - border: 1px solid #ddd; + border: 1px solid var(--border-brand); border-radius: 5px; - background: white; - color: #171717; + background: var(--bg-surface); + color: var(--text-primary); font-size: 11px; cursor: pointer; } .product-details-page .remove-product:hover { - background: #fce8ef; + background: var(--bg-tint); } .product-details-page .shopping-list-controls label { display: block; margin-top: 24px; margin-bottom: 10px; - color: #777; + color: var(--text-muted); font-size: 10px; letter-spacing: 1px; } @@ -431,7 +449,7 @@ border-bottom: 1px solid #f3b4cc; border-radius: 0; background: transparent; - color: #171717; + color: var(--text-primary); font: inherit; font-size: 14px; resize: vertical; @@ -440,7 +458,7 @@ } .product-details-page .shopping-list-controls textarea::placeholder { - color: #888; + color: var(--text-muted); font-style: italic; } @@ -472,12 +490,12 @@ } .product-details-page .product-nutrition dt { - color: #666; + color: var(--text-secondary); } .product-details-page .product-nutrition dd { margin: 0; - color: #171717; + color: var(--text-primary); font-weight: 600; text-align: right; } From ad2550c1f5110f167678009eabadcf718c88c568 Mon Sep 17 00:00:00 2001 From: Tara Aqeel Date: Fri, 16 Oct 2026 15:30:02 +0200 Subject: [PATCH 4/6] style: support dark mode on product details page small fixes --- frontend/src/pages/productDetails/ProductDetailsPage.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/productDetails/ProductDetailsPage.tsx b/frontend/src/pages/productDetails/ProductDetailsPage.tsx index b426228..a55c54a 100644 --- a/frontend/src/pages/productDetails/ProductDetailsPage.tsx +++ b/frontend/src/pages/productDetails/ProductDetailsPage.tsx @@ -55,7 +55,10 @@ export default function ProductDetailsPage({ {/* Venstre side: Produktbilde */}
-
+
{product.imageUrl ? ( ) : ( -

Mangler bilde

+

Ingen bilde

)}
From 8286a30469c275c67d1bf97a9eaeb2a618a9ea67 Mon Sep 17 00:00:00 2001 From: Tara Aqeel Date: Sun, 18 Oct 2026 22:47:34 +0200 Subject: [PATCH 5/6] =?UTF-8?q?Endring=20for=20tilbakemelding=20f=C3=A5tt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../productDetails/ProductDetailsPage.css | 29 ++++--------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/frontend/src/pages/productDetails/ProductDetailsPage.css b/frontend/src/pages/productDetails/ProductDetailsPage.css index 6cdf126..3c9a3ce 100644 --- a/frontend/src/pages/productDetails/ProductDetailsPage.css +++ b/frontend/src/pages/productDetails/ProductDetailsPage.css @@ -10,7 +10,6 @@ background-color: var(--bg-surface); color: var(--text-primary); text-align: left; - color-scheme: light; } .product-details-page *, @@ -82,18 +81,11 @@ .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 { @@ -115,14 +107,14 @@ border: none; } -/* Produkt uten bilde */ +/* Når produktet mangler bilde */ .product-details-page .product-image.no-image { - background-color: #fafafa; + background-color: var(--bg-muted); background-image: - linear-gradient(#e8e8e8 1px, transparent 1px), - linear-gradient(90deg, #e8e8e8 1px, transparent 1px); + linear-gradient(var(--border-grid) 1px, transparent 1px), + linear-gradient(90deg, var(--border-grid) 1px, transparent 1px); background-size: 20px 20px; - border: 1px solid #780b2b; + border: 1px solid var(--border-brand); } .product-details-page .product-image-content { @@ -230,16 +222,9 @@ } .product-details-page .product-cheapest-store { - margin: 0; + margin: 8px 0 0; color: var(--text-muted); font-family: monospace; - font-size: 9px; - letter-spacing: 0.3px; -} - -.product-details-page .product-cheapest-store { - margin-top: 8px; - color: var(--text-muted); font-size: 10px; letter-spacing: 0.5px; } @@ -377,7 +362,6 @@ .product-details-page .quantity-controls { display: inline-flex; align-items: center; - margin-top: 14px; border: 1px solid var(--border-hairline); border-radius: 5px; overflow: hidden; @@ -443,7 +427,6 @@ .product-details-page .shopping-list-controls textarea { display: block; width: 100%; - min-height: 42px; padding: 8px 0; border: none; border-bottom: 1px solid #f3b4cc; From 13b0a9d22c37f0123fcd289327ab4302a5b112a8 Mon Sep 17 00:00:00 2001 From: Tara Aqeel Date: Sun, 18 Oct 2026 23:03:44 +0200 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20vis=20lesbare=20butikknavn=20p=C3=A5?= =?UTF-8?q?=20produktdetaljsiden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../productDetails/ProductDetailsPage.tsx | 53 +++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/frontend/src/pages/productDetails/ProductDetailsPage.tsx b/frontend/src/pages/productDetails/ProductDetailsPage.tsx index a55c54a..6723a18 100644 --- a/frontend/src/pages/productDetails/ProductDetailsPage.tsx +++ b/frontend/src/pages/productDetails/ProductDetailsPage.tsx @@ -7,6 +7,21 @@ type ProductDetailsPageProps = { ean: string; }; +const storeNames: Record = { + KIWI: "Kiwi", + MENY_NO: "Meny", + SPAR_NO: "Spar", + BUNNPRIS: "Bunnpris", + COOP_NO: "Coop", + JOKER_NO: "Joker", + ODA_NO: "Oda", + EUROPRIS_NO: "Europris", + ENGROSSNETT_NO: "Engrosnett", +}; + +const getStoreName = (storeCode: string) => + storeNames[storeCode] ?? storeCode; + export default function ProductDetailsPage({ ean, }: ProductDetailsPageProps) { @@ -16,8 +31,9 @@ export default function ProductDetailsPage({ const product = data?.product; const [quantity, setQuantity] = useState(1); - const [isInList, setIsInList] = useState(true); + const [isInList, setIsInList] = useState(false); const [note, setNote] = useState(""); + const [failedImageUrl, setFailedImageUrl] = useState(null); if (loading) { return
Laster produkt...
; @@ -46,8 +62,12 @@ export default function ProductDetailsPage({ const cheapest = storePrices[0]; const formatPrice = (price: number) => + price.toFixed(2).replace(".", ","); + + const hasImage = Boolean( + product.imageUrl && product.imageUrl !== failedImageUrl + ); - price.toFixed(2).replace(".", ","); return (
@@ -55,24 +75,25 @@ export default function ProductDetailsPage({ {/* Venstre side: Produktbilde */}
-
- {product.imageUrl ? ( - + {hasImage ? ( + {product.name} + onError={() => setFailedImageUrl(product.imageUrl)} + /> ) : ( -

Ingen bilde

+

Mangler bilde

)} -
+
-
+
FIG. 5 — {product.name.toUpperCase()} -
+
@@ -111,7 +132,7 @@ export default function ProductDetailsPage({

- BILLIGST HOS {cheapest.storeCode.toUpperCase()} + BILLIGST HOS {getStoreName(cheapest.storeCode).toUpperCase()}

) : ( @@ -137,7 +158,9 @@ export default function ProductDetailsPage({ {String(index + 1).padStart(2, "0")} - {item.storeCode} + + {getStoreName(item.storeCode)} + {index === 0