Frontend updates
This commit is contained in:
@@ -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 ---
|
||||
|
||||
@@ -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 }) {
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
{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">
|
||||
{product.display_name}
|
||||
</h3>
|
||||
|
||||
@@ -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 <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 }) {
|
||||
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 (
|
||||
<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
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -306,6 +418,8 @@ export function ProductModal({ productId, onClose }) {
|
||||
|
||||
<RatingsAndReviews rating={detail.rating} reviews={detail.reviews || []} />
|
||||
|
||||
<Recommendations key={currentId} similar={recommended} betterRated={betterRated} onOpen={setCurrentId} />
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 font-display text-sm font-semibold text-ink-900">Specifications</h3>
|
||||
{specs.length ? (
|
||||
|
||||
Reference in New Issue
Block a user