Frontend updates
This commit is contained in:
@@ -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 ---
|
||||||
|
|||||||
@@ -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">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">{product.brand}</p>
|
<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">
|
<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>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user