Frontend updates

This commit is contained in:
sriram
2026-10-07 11:07:40 +05:30
parent 71dbb2a6e9
commit 19b6a63021
3 changed files with 136 additions and 7 deletions

View File

@@ -115,6 +115,7 @@ export const api = {
getProducts: (params) => request(`/api/elec/products${qs(params)}`), getProducts: (params) => request(`/api/elec/products${qs(params)}`),
getProduct: (id) => request(`/api/elec/products/${id}`), getProduct: (id) => request(`/api/elec/products/${id}`),
getPriceHistory: (id) => request(`/api/elec/products/${id}/price-history`), 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'), getSites: () => request('/api/elec/sites'),
// --- Admin --- // --- Admin ---

View File

@@ -1,5 +1,5 @@
import React from 'react'; 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'; import { formatINR, variantText } from '../lib/format';
export function ProductCard({ product, onOpen }) { export function ProductCard({ product, onOpen }) {
@@ -26,7 +26,21 @@ export function ProductCard({ product, onOpen }) {
)} )}
</div> </div>
<div className="flex flex-1 flex-col gap-1.5 p-3.5"> <div className="flex flex-1 flex-col gap-1.5 p-3.5">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">{product.brand}</p> <p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">{product.brand}</p>
{product.rating != null && (
<span
className="inline-flex shrink-0 items-center gap-0.5 rounded-full bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-600"
title={product.rating_count ? `${product.rating_count.toLocaleString('en-IN')} ratings` : 'Rating'}
>
<Star className="h-3 w-3" fill="currentColor" strokeWidth={0} />
{product.rating.toFixed(1)}
{product.rating_count ? (
<span className="font-normal text-slate-500">({product.rating_count.toLocaleString('en-IN')})</span>
) : null}
</span>
)}
</div>
<h3 className="font-display text-sm font-semibold leading-snug text-ink-950 group-hover:text-ink-700"> <h3 className="font-display text-sm font-semibold leading-snug text-ink-950 group-hover:text-ink-700">
{product.display_name} {product.display_name}
</h3> </h3>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { X, ExternalLink, MapPin, Info, Star, MessageSquareText } from 'lucide-react'; import { X, ExternalLink, MapPin, Info, Star, MessageSquareText, Sparkles } from 'lucide-react';
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { api } from '../api/client'; import { api } from '../api/client';
import { Spinner } from './Atoms'; 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 <Spinner label="Finding similar products…" />;
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 (
<section>
{variants.length > 0 && (
<div className="mb-3">
<h3 className="mb-2 font-display text-sm font-semibold text-ink-900">Other variants</h3>
<div className="flex flex-wrap gap-2">
{variants.map((v) => (
<button
key={v.product_id}
onClick={() => onOpen(v.product_id)}
className="rounded-full border border-ink-900/10 bg-white px-3 py-1 text-xs text-ink-800 hover:border-ink-900/30"
>
{variantText(v) || v.display_name}
{v.best_price && <span className="ml-1.5 font-semibold text-ink-950">{formatINR(v.best_price)}</span>}
</button>
))}
</div>
</div>
)}
{(similar.items.length > 0 || betterRated?.items.length > 0) && (
<>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-1.5 font-display text-sm font-semibold text-ink-900">
<Sparkles className="h-4 w-4 text-amber-500" /> You may also like
</h3>
<div role="tablist" className="inline-flex rounded-full border border-ink-900/10 bg-white p-0.5 text-xs">
{RECOMMENDATION_TABS.map((t) => (
<button
key={t.key}
role="tab"
aria-selected={tab === t.key}
onClick={() => setTab(t.key)}
className={`rounded-full px-3 py-1 ${tab === t.key ? 'bg-ink-900 text-white' : 'text-ink-700 hover:bg-ink-50'}`}
>
{t.label}
</button>
))}
</div>
</div>
{tab === 'better_rated' && !betterRated && <Spinner label="Finding better rated products…" />}
{lists[tab] && !items.length && (
<p className="rounded-xl border border-dashed border-ink-900/15 bg-white/60 p-4 text-xs text-slate-500">
{RECOMMENDATION_TABS.find((t) => t.key === tab).empty}
</p>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{items.map((r) => (
<button
key={r.product_id}
onClick={() => onOpen(r.product_id)}
className="flex flex-col gap-1.5 rounded-xl border border-ink-900/10 bg-white p-3 text-left hover:border-ink-900/30 hover:shadow-sm"
>
<div className="flex h-24 w-full items-center justify-center">
{r.image_url ? (
<img src={r.image_url} alt="" referrerPolicy="no-referrer" className="max-h-full max-w-full object-contain" />
) : (
<span className="text-[11px] text-slate-400">No image</span>
)}
</div>
<p className="line-clamp-2 text-xs font-medium text-ink-950">{r.display_name}</p>
<p className="text-sm font-semibold text-ink-950">{formatINR(r.best_price)}</p>
{r.rating != null && (
<p className="flex items-center gap-1 text-[11px] text-slate-500">
<Stars value={r.rating} size="h-3 w-3" /> {r.rating.toFixed(1)}
{r.rating_count ? ` (${r.rating_count.toLocaleString('en-IN')})` : ''}
</p>
)}
<p className="mt-auto text-[10px] text-slate-400">{r.reason}</p>
</button>
))}
</div>
</>
)}
</section>
);
}
function PriceHistory({ rows }) { function PriceHistory({ rows }) {
const { data, sites } = useMemo(() => { const { data, sites } = useMemo(() => {
const siteNames = [...new Set(rows.map((r) => r.site))]; const siteNames = [...new Set(rows.map((r) => r.site))];
@@ -172,24 +261,47 @@ function PriceHistory({ rows }) {
} }
export function ProductModal({ productId, onClose }) { 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 [detail, setDetail] = useState(null);
const [history, setHistory] = useState([]); const [history, setHistory] = useState([]);
const [recommended, setRecommended] = useState(null);
const [betterRated, setBetterRated] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [imageIndex, setImageIndex] = useState(0); const [imageIndex, setImageIndex] = useState(0);
const scrollRef = useRef(null);
useEffect(() => setCurrentId(productId), [productId]);
useEffect(() => { useEffect(() => {
let alive = true; 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]) => { .then(([d, h]) => {
if (!alive) return; if (!alive) return;
setDetail(d); setDetail(d);
setHistory(h); setHistory(h);
}) })
.catch((e) => alive && setError(e.message)); .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 () => { return () => {
alive = false; alive = false;
}; };
}, [productId]); }, [currentId]);
useEffect(() => { useEffect(() => {
const onKey = (e) => e.key === 'Escape' && onClose(); const onKey = (e) => e.key === 'Escape' && onClose();
@@ -201,7 +313,7 @@ export function ProductModal({ productId, onClose }) {
const image = detail?.images?.[imageIndex]; const image = detail?.images?.[imageIndex];
return ( return (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-ink-950/50 p-4 backdrop-blur-sm" onClick={onClose}> <div ref={scrollRef} className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-ink-950/50 p-4 backdrop-blur-sm" onClick={onClose}>
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@@ -306,6 +418,8 @@ export function ProductModal({ productId, onClose }) {
<RatingsAndReviews rating={detail.rating} reviews={detail.reviews || []} /> <RatingsAndReviews rating={detail.rating} reviews={detail.reviews || []} />
<Recommendations key={currentId} similar={recommended} betterRated={betterRated} onOpen={setCurrentId} />
<section> <section>
<h3 className="mb-2 font-display text-sm font-semibold text-ink-900">Specifications</h3> <h3 className="mb-2 font-display text-sm font-semibold text-ink-900">Specifications</h3>
{specs.length ? ( {specs.length ? (