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 ? (