From 19b6a630211364aa645c9217a4fe857d9c6679ec Mon Sep 17 00:00:00 2001 From: sriram Date: Wed, 7 Oct 2026 11:07:40 +0530 Subject: [PATCH] Frontend updates --- frontend/src/api/client.js | 1 + frontend/src/components/ProductCard.jsx | 18 +++- frontend/src/components/ProductModal.jsx | 124 ++++++++++++++++++++++- 3 files changed, 136 insertions(+), 7 deletions(-) diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index f23b372..60a7b89 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -115,6 +115,7 @@ export const api = { getProducts: (params) => request(`/api/elec/products${qs(params)}`), getProduct: (id) => request(`/api/elec/products/${id}`), getPriceHistory: (id) => request(`/api/elec/products/${id}/price-history`), + getRecommendations: (id, params) => request(`/api/elec/products/${id}/recommendations${qs(params)}`), getSites: () => request('/api/elec/sites'), // --- Admin --- diff --git a/frontend/src/components/ProductCard.jsx b/frontend/src/components/ProductCard.jsx index 02dd928..51e1015 100644 --- a/frontend/src/components/ProductCard.jsx +++ b/frontend/src/components/ProductCard.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Store, MapPin, ImageOff } from 'lucide-react'; +import { Store, MapPin, ImageOff, Star } from 'lucide-react'; import { formatINR, variantText } from '../lib/format'; export function ProductCard({ product, onOpen }) { @@ -26,7 +26,21 @@ export function ProductCard({ product, onOpen }) { )}
-

{product.brand}

+
+

{product.brand}

+ {product.rating != null && ( + + + {product.rating.toFixed(1)} + {product.rating_count ? ( + ({product.rating_count.toLocaleString('en-IN')}) + ) : null} + + )} +

{product.display_name}

diff --git a/frontend/src/components/ProductModal.jsx b/frontend/src/components/ProductModal.jsx index f4090eb..eeb38a2 100644 --- a/frontend/src/components/ProductModal.jsx +++ b/frontend/src/components/ProductModal.jsx @@ -1,5 +1,5 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { X, ExternalLink, MapPin, Info, Star, MessageSquareText } from 'lucide-react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { X, ExternalLink, MapPin, Info, Star, MessageSquareText, Sparkles } from 'lucide-react'; import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { api } from '../api/client'; import { Spinner } from './Atoms'; @@ -138,6 +138,95 @@ function RatingsAndReviews({ rating, reviews }) { ); } +const RECOMMENDATION_TABS = [ + { key: 'similar', label: 'Similar', empty: 'No similar products in this price range yet.' }, + { key: 'better_rated', label: 'Better rated', empty: 'Nothing rated higher by enough buyers in this price range.' }, +]; + +function Recommendations({ similar, betterRated, onOpen }) { + const [tab, setTab] = useState('similar'); + if (!similar) return ; + const variants = similar.other_variants; + const lists = { similar: similar.items, better_rated: betterRated?.items }; + const items = lists[tab] || []; + if (!similar.items.length && !betterRated?.items.length && !variants.length) return null; + return ( +
+ {variants.length > 0 && ( +
+

Other variants

+
+ {variants.map((v) => ( + + ))} +
+
+ )} + {(similar.items.length > 0 || betterRated?.items.length > 0) && ( + <> +
+

+ You may also like +

+
+ {RECOMMENDATION_TABS.map((t) => ( + + ))} +
+
+ {tab === 'better_rated' && !betterRated && } + {lists[tab] && !items.length && ( +

+ {RECOMMENDATION_TABS.find((t) => t.key === tab).empty} +

+ )} +
+ {items.map((r) => ( + + ))} +
+ + )} +
+ ); +} + function PriceHistory({ rows }) { const { data, sites } = useMemo(() => { const siteNames = [...new Set(rows.map((r) => r.site))]; @@ -172,24 +261,47 @@ function PriceHistory({ rows }) { } export function ProductModal({ productId, onClose }) { + // Opening a recommended product replaces this one in the same popup. + const [currentId, setCurrentId] = useState(productId); const [detail, setDetail] = useState(null); const [history, setHistory] = useState([]); + const [recommended, setRecommended] = useState(null); + const [betterRated, setBetterRated] = useState(null); const [error, setError] = useState(null); const [imageIndex, setImageIndex] = useState(0); + const scrollRef = useRef(null); + + useEffect(() => setCurrentId(productId), [productId]); useEffect(() => { let alive = true; - Promise.all([api.getProduct(productId), api.getPriceHistory(productId)]) + setDetail(null); + setRecommended(null); + setBetterRated(null); + setError(null); + setImageIndex(0); + scrollRef.current?.scrollTo({ top: 0 }); + Promise.all([api.getProduct(currentId), api.getPriceHistory(currentId)]) .then(([d, h]) => { if (!alive) return; setDetail(d); setHistory(h); }) .catch((e) => alive && setError(e.message)); + // Recommendations load on their own so a slow or failed call never holds up the product. + const none = { items: [], other_variants: [] }; + api + .getRecommendations(currentId, { type: 'similar' }) + .then((r) => alive && setRecommended(r)) + .catch(() => alive && setRecommended(none)); + api + .getRecommendations(currentId, { type: 'better_rated' }) + .then((r) => alive && setBetterRated(r)) + .catch(() => alive && setBetterRated(none)); return () => { alive = false; }; - }, [productId]); + }, [currentId]); useEffect(() => { const onKey = (e) => e.key === 'Escape' && onClose(); @@ -201,7 +313,7 @@ export function ProductModal({ productId, onClose }) { const image = detail?.images?.[imageIndex]; return ( -
+
+ +

Specifications

{specs.length ? (