feat(marketplace): improve ProductSearch with grouping, modal, and filters

- Add Shell layout to restore header navigation
- Filter out products from logged-in user's own pharmacy
- Group medications by name (e.g., Losartana 50mg shows as 1 card with '3 offers')
- Create GroupedProductCard component with offer count badge
- Create ProductOffersModal with sorted offers and add-to-cart
- Implement advanced filters (price range, minimum expiry days)
- Add GroupedProduct interface to types
- Sort grouped products by lowest price
This commit is contained in:
Tiago Yamamoto 2025-12-23 16:13:19 -03:00
parent 3a618fa466
commit 1600591f86
4 changed files with 444 additions and 84 deletions

View file

@ -0,0 +1,67 @@
import { GroupedProduct, ProductWithDistance } from '../types/product'
import { formatCents } from '../utils/format'
interface GroupedProductCardProps {
group: GroupedProduct
onClick: () => void
}
export const GroupedProductCard = ({ group, onClick }: GroupedProductCardProps) => {
const nearestOffer = group.offers.reduce((a, b) => a.distance_km < b.distance_km ? a : b)
const soonestExpiry = group.offers.reduce((a, b) =>
new Date(a.expires_at).getTime() < new Date(b.expires_at).getTime() ? a : b
)
const isExpiringSoon = new Date(soonestExpiry.expires_at).getTime() - Date.now() < 30 * 24 * 60 * 60 * 1000
return (
<div
className="bg-white rounded-lg shadow-md p-4 hover:shadow-xl transition-all cursor-pointer border border-gray-100 hover:border-blue-300"
onClick={onClick}
>
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="text-lg font-semibold text-gray-800">{group.name}</h3>
<p className="text-sm text-gray-500">{group.description}</p>
</div>
<div className="flex flex-col items-end gap-1">
{isExpiringSoon && (
<span className="bg-amber-100 text-amber-800 text-xs px-2 py-1 rounded-full font-medium">
Vence em breve
</span>
)}
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full font-medium">
{group.offerCount} {group.offerCount === 1 ? 'oferta' : 'ofertas'}
</span>
</div>
</div>
<div className="mt-4 space-y-2">
<div className="flex items-center text-sm text-blue-600">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Mais próximo: {nearestOffer.distance_km} km</span>
</div>
<div className="flex justify-between items-end mt-4">
<div>
<p className="text-xs text-gray-500">A partir de</p>
<div className="text-2xl font-bold text-green-600">
{formatCents(group.minPriceCents)}
</div>
</div>
<button
className="bg-primary hover:bg-primary-dark text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
Ver ofertas
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,148 @@
import { GroupedProduct, ProductWithDistance } from '../types/product'
import { formatCents } from '../utils/format'
import { useCartStore } from '../stores/cartStore'
interface ProductOffersModalProps {
group: GroupedProduct
onClose: () => void
}
export const ProductOffersModal = ({ group, onClose }: ProductOffersModalProps) => {
const addItem = useCartStore((state) => state.addItem)
const handleAddToCart = (product: ProductWithDistance) => {
addItem({
id: product.id,
name: product.name,
activeIngredient: product.description,
lab: '',
batch: product.batch,
expiry: product.expires_at,
vendorId: product.seller_id,
vendorName: `${product.tenant_city}/${product.tenant_state}`,
unitPrice: product.price_cents
})
}
// Sort by price (cheapest first)
const sortedOffers = [...group.offers].sort((a, b) => a.price_cents - b.price_cents)
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onClose}>
<div
className="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[80vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 text-white p-6">
<div className="flex justify-between items-start">
<div>
<h2 className="text-2xl font-bold">{group.name}</h2>
<p className="text-blue-100 mt-1">{group.description}</p>
</div>
<button
onClick={onClose}
className="text-white/80 hover:text-white hover:bg-white/20 rounded-full p-2 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="mt-4 flex items-center gap-4 text-sm">
<span className="bg-white/20 px-3 py-1 rounded-full">
{group.offerCount} {group.offerCount === 1 ? 'oferta disponível' : 'ofertas disponíveis'}
</span>
<span className="bg-green-500 px-3 py-1 rounded-full">
A partir de {formatCents(group.minPriceCents)}
</span>
</div>
</div>
{/* Offers List */}
<div className="overflow-y-auto max-h-[50vh] p-4">
<div className="space-y-3">
{sortedOffers.map((offer, index) => {
const isExpiringSoon = new Date(offer.expires_at).getTime() - Date.now() < 30 * 24 * 60 * 60 * 1000
const isCheapest = index === 0
return (
<div
key={offer.id}
className={`border rounded-lg p-4 hover:shadow-md transition-shadow ${isCheapest ? 'border-green-300 bg-green-50' : 'border-gray-200'
}`}
>
<div className="flex justify-between items-start gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{isCheapest && (
<span className="bg-green-600 text-white text-xs px-2 py-0.5 rounded font-medium">
Menor preço
</span>
)}
{isExpiringSoon && (
<span className="bg-amber-100 text-amber-800 text-xs px-2 py-0.5 rounded font-medium">
Vence em breve
</span>
)}
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-gray-500">Lote:</span>
<span className="ml-2 font-mono text-gray-700">{offer.batch}</span>
</div>
<div>
<span className="text-gray-500">Validade:</span>
<span className={`ml-2 font-medium ${isExpiringSoon ? 'text-amber-600' : 'text-gray-700'}`}>
{new Date(offer.expires_at).toLocaleDateString()}
</span>
</div>
<div className="col-span-2 flex items-center text-blue-600">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>{offer.distance_km} km {offer.tenant_city}/{offer.tenant_state}</span>
</div>
<div>
<span className="text-gray-500">Estoque:</span>
<span className="ml-2 font-medium text-gray-700">{offer.stock} un.</span>
</div>
</div>
</div>
<div className="text-right flex flex-col items-end gap-2">
<div className="text-2xl font-bold text-green-600">
{formatCents(offer.price_cents)}
</div>
<button
onClick={() => handleAddToCart(offer)}
className="bg-primary hover:bg-primary-dark text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
Adicionar
</button>
</div>
</div>
</div>
)
})}
</div>
</div>
{/* Footer */}
<div className="border-t p-4 bg-gray-50">
<button
onClick={onClose}
className="w-full py-2 text-gray-600 hover:text-gray-800 font-medium"
>
Fechar
</button>
</div>
</div>
</div>
)
}

View file

@ -1,11 +1,14 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo } from 'react'
import { Shell } from '../layouts/Shell'
import { productService } from '../services/productService' import { productService } from '../services/productService'
import { LocationPicker } from '../components/LocationPicker' import { LocationPicker } from '../components/LocationPicker'
import { ProductCard } from '../components/ProductCard' import { GroupedProductCard } from '../components/GroupedProductCard'
import { ProductWithDistance } from '../types/product' import { ProductOffersModal } from '../components/ProductOffersModal'
import { useDebounce } from '../hooks/useDebounce' // Assuming this hook exists or I'll generic implement it import { ProductWithDistance, GroupedProduct } from '../types/product'
import { useAuth } from '../context/AuthContext'
const ProductSearch = () => { const ProductSearch = () => {
const { user } = useAuth()
const [lat, setLat] = useState<number>(-16.3285) const [lat, setLat] = useState<number>(-16.3285)
const [lng, setLng] = useState<number>(-48.9534) const [lng, setLng] = useState<number>(-48.9534)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@ -14,6 +17,15 @@ const ProductSearch = () => {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
// Advanced filters
const [minPrice, setMinPrice] = useState<number | ''>('')
const [maxPrice, setMaxPrice] = useState<number | ''>('')
const [minExpiryDays, setMinExpiryDays] = useState<number>(0)
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
// Modal state
const [selectedGroup, setSelectedGroup] = useState<GroupedProduct | null>(null)
// Debounce search term // Debounce search term
const [debouncedSearch, setDebouncedSearch] = useState(search) const [debouncedSearch, setDebouncedSearch] = useState(search)
useEffect(() => { useEffect(() => {
@ -30,8 +42,11 @@ const ProductSearch = () => {
lng, lng,
search: debouncedSearch, search: debouncedSearch,
max_distance: maxDistance, max_distance: maxDistance,
min_price: minPrice !== '' ? minPrice * 100 : undefined,
max_price: maxPrice !== '' ? maxPrice * 100 : undefined,
min_expiry_days: minExpiryDays > 0 ? minExpiryDays : undefined,
page: 1, page: 1,
page_size: 50, page_size: 100,
}) })
const safeProducts = Array.isArray(data.products) ? data.products : [] const safeProducts = Array.isArray(data.products) ? data.products : []
setProducts(safeProducts) setProducts(safeProducts)
@ -44,21 +59,56 @@ const ProductSearch = () => {
} }
fetchProducts() fetchProducts()
}, [lat, lng, debouncedSearch, maxDistance]) }, [lat, lng, debouncedSearch, maxDistance, minPrice, maxPrice, minExpiryDays])
// Filter out products from the logged-in user's pharmacy and group by name
const groupedProducts = useMemo(() => {
// Filter out own products
const filteredProducts = products.filter(p => p.seller_id !== user?.id)
// Group by name (case-insensitive)
const groups: Record<string, GroupedProduct> = {}
for (const product of filteredProducts) {
const key = product.name.toLowerCase().trim()
if (!groups[key]) {
groups[key] = {
name: product.name,
description: product.description,
offers: [],
minPriceCents: product.price_cents,
offerCount: 0
}
}
groups[key].offers.push(product)
groups[key].offerCount++
if (product.price_cents < groups[key].minPriceCents) {
groups[key].minPriceCents = product.price_cents
}
}
// Sort by min price
return Object.values(groups).sort((a, b) => a.minPriceCents - b.minPriceCents)
}, [products, user?.id])
const handleLocationSelect = (newLat: number, newLng: number) => { const handleLocationSelect = (newLat: number, newLng: number) => {
setLat(newLat) setLat(newLat)
setLng(newLng) setLng(newLng)
} }
const addToCart = (product: ProductWithDistance) => { const clearFilters = () => {
console.log('Added to cart:', product) setSearch('')
// Implement cart logic later setMinPrice('')
alert(`Adicionado ao carrinho: ${product.name}`) setMaxPrice('')
setMinExpiryDays(0)
setMaxDistance(10)
} }
return ( return (
<div className="container mx-auto px-4 py-8"> <Shell>
<div className="container mx-auto">
<h1 className="text-3xl font-bold mb-6 text-gray-800">Encontre Medicamentos Próximos</h1> <h1 className="text-3xl font-bold mb-6 text-gray-800">Encontre Medicamentos Próximos</h1>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
@ -103,6 +153,81 @@ const ProductSearch = () => {
className="w-full mt-2" className="w-full mt-2"
/> />
</div> </div>
{/* Toggle Advanced Filters */}
<button
type="button"
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
className="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`h-4 w-4 transition-transform ${showAdvancedFilters ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
{showAdvancedFilters ? 'Ocultar filtros avançados' : 'Mostrar filtros avançados'}
</button>
{showAdvancedFilters && (
<div className="space-y-4 pt-4 border-t border-gray-200">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-gray-700">Preço mín (R$)</label>
<input
type="number"
min="0"
step="0.01"
value={minPrice}
onChange={(e) => setMinPrice(e.target.value ? Number(e.target.value) : '')}
placeholder="0,00"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm sm:text-sm p-2 border"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700">Preço máx (R$)</label>
<input
type="number"
min="0"
step="0.01"
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value ? Number(e.target.value) : '')}
placeholder="999,99"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm sm:text-sm p-2 border"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700">
Validade mínima: <span className="text-primary font-bold">{minExpiryDays} dias</span>
</label>
<input
type="range"
min="0"
max="365"
step="30"
value={minExpiryDays}
onChange={(e) => setMinExpiryDays(Number(e.target.value))}
className="w-full mt-2"
/>
<p className="text-xs text-gray-500 mt-1">
Excluir produtos que vencem antes de {minExpiryDays} dias
</p>
</div>
<button
type="button"
onClick={clearFilters}
className="w-full text-sm text-red-600 hover:text-red-800 py-2 border border-red-200 rounded-md hover:bg-red-50"
>
Limpar todos os filtros
</button>
</div>
)}
</div> </div>
</div> </div>
@ -110,10 +235,10 @@ const ProductSearch = () => {
<div className="lg:col-span-3"> <div className="lg:col-span-3">
<div className="mb-4 flex justify-between items-center"> <div className="mb-4 flex justify-between items-center">
<h2 className="text-xl font-semibold text-gray-700"> <h2 className="text-xl font-semibold text-gray-700">
{loading ? 'Buscando...' : `${total} produtos encontrados`} {loading ? 'Buscando...' : `${groupedProducts.length} medicamentos (${total} ofertas)`}
</h2> </h2>
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
Ordenado por: <span className="font-medium text-gray-800">Vencimento (mais próximo)</span> Ordenado por: <span className="font-medium text-gray-800">Menor preço</span>
</div> </div>
</div> </div>
@ -125,15 +250,15 @@ const ProductSearch = () => {
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{products.map((product) => ( {groupedProducts.map((group) => (
<ProductCard <GroupedProductCard
key={product.id} key={group.name}
product={product} group={group}
onAddToCart={addToCart} onClick={() => setSelectedGroup(group)}
/> />
))} ))}
{products.length === 0 && ( {groupedProducts.length === 0 && (
<div className="col-span-full text-center py-12 bg-white rounded-lg border border-dashed border-gray-300"> <div className="col-span-full text-center py-12 bg-white rounded-lg border border-dashed border-gray-300">
<p className="text-gray-500 text-lg">Nenhum produto encontrado nesta região.</p> <p className="text-gray-500 text-lg">Nenhum produto encontrado nesta região.</p>
<p className="text-gray-400">Tente aumentar o raio de busca ou mudar a localização.</p> <p className="text-gray-400">Tente aumentar o raio de busca ou mudar a localização.</p>
@ -144,6 +269,15 @@ const ProductSearch = () => {
</div> </div>
</div> </div>
</div> </div>
{/* Modal */}
{selectedGroup && (
<ProductOffersModal
group={selectedGroup}
onClose={() => setSelectedGroup(null)}
/>
)}
</Shell>
) )
} }

View file

@ -17,12 +17,23 @@ export interface ProductWithDistance extends Product {
tenant_state: string tenant_state: string
} }
export interface GroupedProduct {
name: string
description: string
offers: ProductWithDistance[]
minPriceCents: number
offerCount: number
}
export interface ProductSearchFilters { export interface ProductSearchFilters {
search?: string search?: string
min_price?: number min_price?: number
max_price?: number max_price?: number
max_distance?: number max_distance?: number
expires_before?: number expires_before?: number
category?: string
min_expiry_days?: number
exclude_seller_id?: string
lat: number lat: number
lng: number lng: number
page?: number page?: number