From 1600591f86d67a2ce4d136997a69f796473031b2 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Tue, 23 Dec 2025 16:13:19 -0300 Subject: [PATCH] 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 --- .../src/components/GroupedProductCard.tsx | 67 ++++ .../src/components/ProductOffersModal.tsx | 148 +++++++++ marketplace/src/pages/ProductSearch.tsx | 302 +++++++++++++----- marketplace/src/types/product.ts | 11 + 4 files changed, 444 insertions(+), 84 deletions(-) create mode 100644 marketplace/src/components/GroupedProductCard.tsx create mode 100644 marketplace/src/components/ProductOffersModal.tsx diff --git a/marketplace/src/components/GroupedProductCard.tsx b/marketplace/src/components/GroupedProductCard.tsx new file mode 100644 index 0000000..bb66c5f --- /dev/null +++ b/marketplace/src/components/GroupedProductCard.tsx @@ -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 ( +
+
+
+

{group.name}

+

{group.description}

+
+
+ {isExpiringSoon && ( + + Vence em breve + + )} + + {group.offerCount} {group.offerCount === 1 ? 'oferta' : 'ofertas'} + +
+
+ +
+
+ + + + + Mais próximo: {nearestOffer.distance_km} km +
+ +
+
+

A partir de

+
+ {formatCents(group.minPriceCents)} +
+
+ +
+
+
+ ) +} diff --git a/marketplace/src/components/ProductOffersModal.tsx b/marketplace/src/components/ProductOffersModal.tsx new file mode 100644 index 0000000..a2855b9 --- /dev/null +++ b/marketplace/src/components/ProductOffersModal.tsx @@ -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 ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+

{group.name}

+

{group.description}

+
+ +
+
+ + {group.offerCount} {group.offerCount === 1 ? 'oferta disponível' : 'ofertas disponíveis'} + + + A partir de {formatCents(group.minPriceCents)} + +
+
+ + {/* Offers List */} +
+
+ {sortedOffers.map((offer, index) => { + const isExpiringSoon = new Date(offer.expires_at).getTime() - Date.now() < 30 * 24 * 60 * 60 * 1000 + const isCheapest = index === 0 + + return ( +
+
+
+
+ {isCheapest && ( + + Menor preço + + )} + {isExpiringSoon && ( + + Vence em breve + + )} +
+ +
+
+ Lote: + {offer.batch} +
+
+ Validade: + + {new Date(offer.expires_at).toLocaleDateString()} + +
+
+ + + + + {offer.distance_km} km • {offer.tenant_city}/{offer.tenant_state} +
+
+ Estoque: + {offer.stock} un. +
+
+
+ +
+
+ {formatCents(offer.price_cents)} +
+ +
+
+
+ ) + })} +
+
+ + {/* Footer */} +
+ +
+
+
+ ) +} diff --git a/marketplace/src/pages/ProductSearch.tsx b/marketplace/src/pages/ProductSearch.tsx index c757e86..71736b3 100644 --- a/marketplace/src/pages/ProductSearch.tsx +++ b/marketplace/src/pages/ProductSearch.tsx @@ -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 { LocationPicker } from '../components/LocationPicker' -import { ProductCard } from '../components/ProductCard' -import { ProductWithDistance } from '../types/product' -import { useDebounce } from '../hooks/useDebounce' // Assuming this hook exists or I'll generic implement it +import { GroupedProductCard } from '../components/GroupedProductCard' +import { ProductOffersModal } from '../components/ProductOffersModal' +import { ProductWithDistance, GroupedProduct } from '../types/product' +import { useAuth } from '../context/AuthContext' const ProductSearch = () => { + const { user } = useAuth() const [lat, setLat] = useState(-16.3285) const [lng, setLng] = useState(-48.9534) const [search, setSearch] = useState('') @@ -14,6 +17,15 @@ const ProductSearch = () => { const [loading, setLoading] = useState(false) const [total, setTotal] = useState(0) + // Advanced filters + const [minPrice, setMinPrice] = useState('') + const [maxPrice, setMaxPrice] = useState('') + const [minExpiryDays, setMinExpiryDays] = useState(0) + const [showAdvancedFilters, setShowAdvancedFilters] = useState(false) + + // Modal state + const [selectedGroup, setSelectedGroup] = useState(null) + // Debounce search term const [debouncedSearch, setDebouncedSearch] = useState(search) useEffect(() => { @@ -30,8 +42,11 @@ const ProductSearch = () => { lng, search: debouncedSearch, 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_size: 50, + page_size: 100, }) const safeProducts = Array.isArray(data.products) ? data.products : [] setProducts(safeProducts) @@ -44,106 +59,225 @@ const ProductSearch = () => { } 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 = {} + + 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) => { setLat(newLat) setLng(newLng) } - const addToCart = (product: ProductWithDistance) => { - console.log('Added to cart:', product) - // Implement cart logic later - alert(`Adicionado ao carrinho: ${product.name}`) + const clearFilters = () => { + setSearch('') + setMinPrice('') + setMaxPrice('') + setMinExpiryDays(0) + setMaxDistance(10) } return ( -
-

Encontre Medicamentos Próximos

+ +
+

Encontre Medicamentos Próximos

-
- {/* Filters & Map */} -
-
-

Sua Localização

- -

- Clique no mapa para ajustar sua localização. -

-
- -
-

Filtros

- -
- - setSearch(e.target.value)} - placeholder="Nome do medicamento..." - className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary focus:ring-primary sm:text-sm p-2 border" +
+ {/* Filters & Map */} +
+
+

Sua Localização

+ +

+ Clique no mapa para ajustar sua localização. +

-
- - setMaxDistance(Number(e.target.value))} - className="w-full mt-2" - /> -
-
-
+
+

Filtros

- {/* Results */} -
-
-

- {loading ? 'Buscando...' : `${total} produtos encontrados`} -

-
- Ordenado por: Vencimento (mais próximo) -
-
- - {loading ? ( -
- {[...Array(6)].map((_, i) => ( -
- ))} -
- ) : ( -
- {products.map((product) => ( - + + setSearch(e.target.value)} + placeholder="Nome do medicamento..." + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary focus:ring-primary sm:text-sm p-2 border" /> - ))} +
- {products.length === 0 && ( -
-

Nenhum produto encontrado nesta região.

-

Tente aumentar o raio de busca ou mudar a localização.

+
+ + setMaxDistance(Number(e.target.value))} + className="w-full mt-2" + /> +
+ + {/* Toggle Advanced Filters */} + + + {showAdvancedFilters && ( +
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ + setMinExpiryDays(Number(e.target.value))} + className="w-full mt-2" + /> +

+ Excluir produtos que vencem antes de {minExpiryDays} dias +

+
+ +
)}
- )} +
+ + {/* Results */} +
+
+

+ {loading ? 'Buscando...' : `${groupedProducts.length} medicamentos (${total} ofertas)`} +

+
+ Ordenado por: Menor preço +
+
+ + {loading ? ( +
+ {[...Array(6)].map((_, i) => ( +
+ ))} +
+ ) : ( +
+ {groupedProducts.map((group) => ( + setSelectedGroup(group)} + /> + ))} + + {groupedProducts.length === 0 && ( +
+

Nenhum produto encontrado nesta região.

+

Tente aumentar o raio de busca ou mudar a localização.

+
+ )} +
+ )} +
-
+ + {/* Modal */} + {selectedGroup && ( + setSelectedGroup(null)} + /> + )} + ) } diff --git a/marketplace/src/types/product.ts b/marketplace/src/types/product.ts index 64c8f4f..b443528 100644 --- a/marketplace/src/types/product.ts +++ b/marketplace/src/types/product.ts @@ -17,12 +17,23 @@ export interface ProductWithDistance extends Product { tenant_state: string } +export interface GroupedProduct { + name: string + description: string + offers: ProductWithDistance[] + minPriceCents: number + offerCount: number +} + export interface ProductSearchFilters { search?: string min_price?: number max_price?: number max_distance?: number expires_before?: number + category?: string + min_expiry_days?: number + exclude_seller_id?: string lat: number lng: number page?: number