chore: refactor backend config, unignore .env, update config loading

This commit is contained in:
Tiago Yamamoto 2025-12-20 10:10:55 -03:00
parent 4bb848788f
commit 25cee3911c
15 changed files with 480 additions and 114 deletions

4
.gitignore vendored
View file

@ -12,9 +12,7 @@ Thumbs.db
.Trashes
# Environment variables
.env
.env.local
.env.*.local
# Logs
*.log

21
backend/.env Normal file
View file

@ -0,0 +1,21 @@
# ============================================
# SaveInMed Backend - Environment Variables
# ============================================
# Application Settings
APP_NAME=saveinmed-performance-core
BACKEND_PORT=8214
# Database Configuration
DATABASE_URL=postgres://yuki:xl1zfmr6e9bb@db-60059.dc-sp-1.absamcloud.com:26868/sim_dev?sslmode=disable
# JWT Authentication
JWT_SECRET=your-secret-key-here
JWT_EXPIRES_IN=24h
# MercadoPago Payment Gateway
MERCADOPAGO_BASE_URL=https://api.mercadopago.com
MARKETPLACE_COMMISSION=2.5
# CORS Configuration (comma-separated list of allowed origins, use * for all)
CORS_ORIGINS=*

3
backend/.gitignore vendored
View file

@ -27,8 +27,7 @@ dist/
*.a
# Environment variables
.env
.env.local
# Logs
*.log

View file

@ -84,6 +84,45 @@ func main() {
ctx := context.Background()
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
// Re-create tables to ensure schema match
log.Println("Re-creating tables...")
mustExec(db, `DROP TABLE IF EXISTS inventory_adjustments CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS order_items CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS orders CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS cart_items CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS reviews CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS products CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS users CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS companies CASCADE`)
mustExec(db, `CREATE TABLE companies (
id UUID PRIMARY KEY,
cnpj TEXT NOT NULL UNIQUE,
corporate_name TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'farmacia',
license_number TEXT NOT NULL,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
latitude DOUBLE PRECISION NOT NULL DEFAULT 0,
longitude DOUBLE PRECISION NOT NULL DEFAULT 0,
city TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
)`)
mustExec(db, `CREATE TABLE products (
id UUID PRIMARY KEY,
seller_id UUID NOT NULL REFERENCES companies(id),
name TEXT NOT NULL,
description TEXT,
batch TEXT NOT NULL,
expires_at DATE NOT NULL,
price_cents BIGINT NOT NULL,
stock BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
)`)
// Generate 400 tenants
tenants := generateTenants(rng, 400)
log.Printf("Generated %d tenants", len(tenants))
@ -198,3 +237,10 @@ func generateCNPJ(rng *rand.Rand) string {
rng.Intn(99), rng.Intn(999), rng.Intn(999),
rng.Intn(9999)+1, rng.Intn(99))
}
func mustExec(db *sqlx.DB, query string) {
_, err := db.Exec(query)
if err != nil {
log.Fatalf("exec %s: %v", query, err)
}
}

View file

@ -29,7 +29,7 @@ type Config struct {
func Load() Config {
cfg := Config{
AppName: getEnv("APP_NAME", "saveinmed-performance-core"),
Port: getEnv("PORT", "8214"),
Port: getEnv("BACKEND_PORT", "8214"),
DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/saveinmed?sslmode=disable"),
MaxOpenConns: getEnvInt("DB_MAX_OPEN_CONNS", 15),
MaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 5),

View file

@ -9,9 +9,12 @@
"version": "0.0.0",
"dependencies": {
"@mercadopago/sdk-react": "^1.0.6",
"@types/leaflet": "^1.9.21",
"axios": "^1.7.4",
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.26.0",
"react-window": "^1.8.10",
"zustand": "^4.5.5"
@ -1093,6 +1096,17 @@
"node": ">= 8"
}
},
"node_modules/@react-leaflet/core": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.1",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz",
@ -1591,6 +1605,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -2818,6 +2847,13 @@
"node": ">=6"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@ -3360,6 +3396,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-leaflet": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^2.1.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",

View file

@ -12,9 +12,12 @@
},
"dependencies": {
"@mercadopago/sdk-react": "^1.0.6",
"@types/leaflet": "^1.9.21",
"axios": "^1.7.4",
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.26.0",
"react-window": "^1.8.10",
"zustand": "^4.5.5"

View file

@ -1,5 +1,6 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { LoginPage } from './pages/Login'
import ProductSearch from './pages/ProductSearch'
import { DashboardPage } from './pages/Dashboard'
import { CartPage } from './pages/Cart'
import { CheckoutPage } from './pages/Checkout'
@ -78,7 +79,8 @@ function App() {
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/dashboard" />} />
<Route path="/search" element={<ProductSearch />} />
<Route path="*" element={<Navigate to="/search" />} />
</Routes>
)
}

View file

@ -0,0 +1,57 @@
import { MapContainer, TileLayer, Marker, useMapEvents } from 'react-leaflet'
import { useState, useEffect } from 'react'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
// Fix generic Leaflet icon missing in webpack/vite
import icon from 'leaflet/dist/images/marker-icon.png'
import iconShadow from 'leaflet/dist/images/marker-shadow.png'
let DefaultIcon = L.icon({
iconUrl: icon,
shadowUrl: iconShadow,
iconSize: [25, 41],
iconAnchor: [12, 41]
})
L.Marker.prototype.options.icon = DefaultIcon
interface LocationPickerProps {
initialLat?: number
initialLng?: number
onLocationSelect: (lat: number, lng: number) => void
}
const LocationMarker = ({ onLocationSelect }: { onLocationSelect: (lat: number, lng: number) => void }) => {
const [position, setPosition] = useState<L.LatLng | null>(null)
const map = useMapEvents({
click(e) {
setPosition(e.latlng)
onLocationSelect(e.latlng.lat, e.latlng.lng)
map.flyTo(e.latlng, map.getZoom())
},
})
return position === null ? null : (
<Marker position={position} />
)
}
export const LocationPicker = ({ initialLat, initialLng, onLocationSelect }: LocationPickerProps) => {
const defaultPosition: [number, number] = [-16.3285, -48.9534] // Anápolis center
const center = (initialLat && initialLng) ? [initialLat, initialLng] as [number, number] : defaultPosition
return (
<div className="h-[300px] w-full rounded-lg overflow-hidden border border-gray-300">
<MapContainer center={center} zoom={13} scrollWheelZoom={true} style={{ height: '100%', width: '100%' }}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<LocationMarker onLocationSelect={onLocationSelect} />
{(initialLat && initialLng) && <Marker position={[initialLat, initialLng]} />}
</MapContainer>
</div>
)
}

View file

@ -0,0 +1,53 @@
import { ProductWithDistance } from '../types/product'
interface ProductCardProps {
product: ProductWithDistance
onAddToCart: (product: ProductWithDistance) => void
}
export const ProductCard = ({ product, onAddToCart }: ProductCardProps) => {
const isExpiringSoon = new Date(product.expires_at).getTime() - Date.now() < 30 * 24 * 60 * 60 * 1000 // 30 days
return (
<div className="bg-white rounded-lg shadow-md p-4 hover:shadow-lg transition-shadow border border-gray-100">
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="text-lg font-semibold text-gray-800">{product.name}</h3>
<p className="text-sm text-gray-500">{product.description}</p>
</div>
{isExpiringSoon && (
<span className="bg-red-100 text-red-800 text-xs px-2 py-1 rounded-full font-medium">
Vence em breve
</span>
)}
</div>
<div className="mt-4 space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-600">Lote: {product.batch}</span>
<span className="font-mono text-gray-500">Val: {new Date(product.expires_at).toLocaleDateString()}</span>
</div>
<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>{product.distance_km} km {product.tenant_city}/{product.tenant_state}</span>
</div>
<div className="flex justify-between items-end mt-4">
<div className="text-2xl font-bold text-green-600">
R$ {(product.price_cents / 100).toFixed(2).replace('.', ',')}
</div>
<button
onClick={() => onAddToCart(product)}
className="bg-primary hover:bg-primary-dark text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
Adicionar
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,17 @@
import { useState, useEffect } from 'react'
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}

View file

@ -1,25 +1,22 @@
import { useMemo, useState } from 'react'
import { FixedSizeList as List, ListChildComponentProps } from 'react-window'
import { usePersistentFilters, defaultFilters } from '../hooks/usePersistentFilters'
import { Product } from '../types/product'
import { useCartStore } from '../stores/cartStore'
import { Shell } from '../layouts/Shell'
// Mock data updated to match new Product interface
const mockProducts: Product[] = Array.from({ length: 500 }).map((_, idx) => {
const lab = ['EMS', 'Eurofarma', 'Aché'][idx % 3]
const actives = ['Amoxicilina', 'Dipirona', 'Losartana']
const categories = ['Antibiótico', 'Analgésico', 'Cardiovascular']
return {
id: `prod-${idx + 1}`,
seller_id: `seller-${idx % 4}`,
name: `Produto ${idx + 1}`,
activeIngredient: actives[idx % actives.length],
lab,
category: categories[idx % categories.length],
description: `Descrição do produto ${idx + 1}`,
batch: `L${1000 + idx}`,
expiry: `202${(idx % 4) + 5}-0${(idx % 9) + 1}`,
vendorId: `vendor-${idx % 4}`,
vendorName: ['Distribuidora Norte', 'Distribuidora Sul', 'FastMed', 'HealthPro'][idx % 4],
price: 10 + (idx % 20)
expires_at: `202${(idx % 4) + 5}-0${(idx % 9) + 1}-01`,
price_cents: 1000 + (idx % 2000),
stock: 100 + idx,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
})
@ -30,29 +27,20 @@ function DenseRow({ index, style, data }: ListChildComponentProps<Product[]>) {
return (
<div
style={style}
className="grid grid-cols-9 items-center gap-2 border-b border-gray-200 bg-gray-50 px-3 text-sm hover:bg-gray-100"
className="grid grid-cols-7 items-center gap-2 border-b border-gray-200 bg-gray-50 px-3 text-sm hover:bg-gray-100"
>
<div className="truncate font-semibold text-gray-800">{product.name}</div>
<div className="truncate text-gray-700">{product.activeIngredient}</div>
<div className="truncate text-gray-700">{product.lab}</div>
<div className="truncate text-gray-700">{product.description}</div>
<div className="truncate text-gray-700">{product.batch}</div>
<div className="truncate text-gray-700">{product.expiry}</div>
<div className="truncate text-gray-700">{product.vendorName}</div>
<div className="text-right font-semibold text-medicalBlue">R$ {product.price.toFixed(2)}</div>
<div className="truncate text-gray-700">{product.expires_at}</div>
<div className="text-right font-semibold text-medicalBlue">R$ {(product.price_cents / 100).toFixed(2)}</div>
<div className="col-span-2 flex items-center justify-end gap-2">
<button
className="rounded bg-healthGreen px-3 py-1 text-xs font-semibold text-white shadow-sm hover:opacity-90"
onClick={() => addItem({
id: product.id,
name: product.name,
activeIngredient: product.activeIngredient,
lab: product.lab,
batch: product.batch,
expiry: product.expiry,
vendorId: product.vendorId,
vendorName: product.vendorName,
unitPrice: product.price
})}
...product,
price: product.price_cents / 100 // Adapter for cart store if needed
} as any)}
>
Adicionar
</button>
@ -62,101 +50,35 @@ function DenseRow({ index, style, data }: ListChildComponentProps<Product[]>) {
}
export function DashboardPage() {
const { filters, setFilters } = usePersistentFilters()
const [search, setSearch] = useState('')
const filtered = useMemo(() => {
return mockProducts.filter((p) => {
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase())
const matchesActive = filters.activeIngredient
? p.activeIngredient === filters.activeIngredient
: true
const matchesCategory = filters.category ? p.category === filters.category : true
const matchesLab = filters.lab ? p.lab === filters.lab : true
return matchesSearch && matchesActive && matchesCategory && matchesLab
return matchesSearch
})
}, [filters.activeIngredient, filters.category, filters.lab, search])
const labs = Array.from(new Set(mockProducts.map((p) => p.lab)))
const actives = Array.from(new Set(mockProducts.map((p) => p.activeIngredient)))
const categories = Array.from(new Set(mockProducts.map((p) => p.category)))
}, [search])
return (
<Shell>
<div className="mb-4 flex flex-col gap-4">
<div className="grid grid-cols-1 gap-3 rounded-lg bg-white p-4 shadow-sm lg:grid-cols-4">
<div className="lg:col-span-2">
<div className="grid grid-cols-1 gap-3 rounded-lg bg-white p-4 shadow-sm">
<div>
<p className="text-sm font-semibold text-gray-700">Busca rápida</p>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Procure por nome ou SKU"
placeholder="Procure por nome"
className="mt-2 w-full rounded border border-gray-200 px-3 py-2"
/>
<p className="mt-1 text-xs text-gray-500">Otimizada para milhares de SKUs com renderização virtualizada.</p>
</div>
<div>
<label className="text-sm font-semibold text-gray-700">Princípio Ativo</label>
<select
className="mt-2 w-full rounded border border-gray-200 px-2 py-2"
value={filters.activeIngredient}
onChange={(e) => setFilters((prev) => ({ ...prev, activeIngredient: e.target.value }))}
>
<option value="">Todos</option>
{actives.map((active) => (
<option key={active} value={active}>
{active}
</option>
))}
</select>
</div>
<div>
<label className="text-sm font-semibold text-gray-700">Categoria</label>
<select
className="mt-2 w-full rounded border border-gray-200 px-2 py-2"
value={filters.category}
onChange={(e) => setFilters((prev) => ({ ...prev, category: e.target.value }))}
>
<option value="">Todas</option>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
<div>
<label className="text-sm font-semibold text-gray-700">Laboratório</label>
<select
className="mt-2 w-full rounded border border-gray-200 px-2 py-2"
value={filters.lab}
onChange={(e) => setFilters((prev) => ({ ...prev, lab: e.target.value }))}
>
<option value="">Todos</option>
{labs.map((lab) => (
<option key={lab} value={lab}>
{lab}
</option>
))}
</select>
</div>
<div className="lg:col-span-4 flex justify-end">
<button
className="rounded bg-gray-200 px-3 py-2 text-xs font-semibold text-gray-700"
onClick={() => setFilters(defaultFilters)}
>
Limpar filtros
</button>
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-gray-200 shadow-inner">
<div className="table-header grid grid-cols-9 gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-wide">
<div className="table-header grid grid-cols-7 gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-wide">
<span>Nome</span>
<span>Princípio Ativo</span>
<span>Laboratório</span>
<span>Descrição</span>
<span>Lote</span>
<span>Validade</span>
<span>Distribuidora</span>
<span className="text-right">Preço</span>
<span className="col-span-2 text-right">Ações</span>
</div>

View file

@ -0,0 +1,149 @@
import { useState, useEffect } from 'react'
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
const ProductSearch = () => {
const [lat, setLat] = useState<number>(-16.3285)
const [lng, setLng] = useState<number>(-48.9534)
const [search, setSearch] = useState('')
const [maxDistance, setMaxDistance] = useState<number>(10)
const [products, setProducts] = useState<ProductWithDistance[]>([])
const [loading, setLoading] = useState(false)
const [total, setTotal] = useState(0)
// Debounce search term
const [debouncedSearch, setDebouncedSearch] = useState(search)
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 500)
return () => clearTimeout(timer)
}, [search])
useEffect(() => {
const fetchProducts = async () => {
setLoading(true)
try {
const data = await productService.search({
lat,
lng,
search: debouncedSearch,
max_distance: maxDistance,
page: 1,
page_size: 50,
})
setProducts(data.products)
setTotal(data.total)
} catch (err) {
console.error('Failed to fetch products', err)
} finally {
setLoading(false)
}
}
fetchProducts()
}, [lat, lng, debouncedSearch, maxDistance])
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}`)
}
return (
<div className="container mx-auto px-4 py-8">
<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">
{/* Filters & Map */}
<div className="lg:col-span-1 space-y-6">
<div className="bg-white p-4 rounded-lg shadow border border-gray-200">
<h2 className="text-lg font-semibold mb-3">Sua Localização</h2>
<LocationPicker
initialLat={lat}
initialLng={lng}
onLocationSelect={handleLocationSelect}
/>
<p className="text-xs text-gray-500 mt-2">
Clique no mapa para ajustar sua localização.
</p>
</div>
<div className="bg-white p-4 rounded-lg shadow border border-gray-200 space-y-4">
<h2 className="text-lg font-semibold">Filtros</h2>
<div>
<label className="block text-sm font-medium text-gray-700">Busca</label>
<input
type="text"
value={search}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Raio de busca: <span className="text-primary font-bold">{maxDistance} km</span>
</label>
<input
type="range"
min="1"
max="50"
value={maxDistance}
onChange={(e) => setMaxDistance(Number(e.target.value))}
className="w-full mt-2"
/>
</div>
</div>
</div>
{/* Results */}
<div className="lg:col-span-3">
<div className="mb-4 flex justify-between items-center">
<h2 className="text-xl font-semibold text-gray-700">
{loading ? 'Buscando...' : `${total} produtos encontrados`}
</h2>
<div className="text-sm text-gray-500">
Ordenado por: <span className="font-medium text-gray-800">Vencimento (mais próximo)</span>
</div>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-64 bg-gray-100 rounded-lg animate-pulse"></div>
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
onAddToCart={addToCart}
/>
))}
{products.length === 0 && (
<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-400">Tente aumentar o raio de busca ou mudar a localização.</p>
</div>
)}
</div>
)}
</div>
</div>
</div>
)
}
export default ProductSearch

View file

@ -0,0 +1,24 @@
import { apiClient } from './apiClient'
import { ProductSearchFilters, ProductSearchPage } from '../types/product'
export const productService = {
search: async (filters: ProductSearchFilters): Promise<ProductSearchPage> => {
// Build query params
const params = new URLSearchParams()
// Required
params.append('lat', filters.lat.toString())
params.append('lng', filters.lng.toString())
if (filters.search) params.append('search', filters.search)
if (filters.min_price) params.append('min_price', filters.min_price.toString())
if (filters.max_price) params.append('max_price', filters.max_price.toString())
if (filters.max_distance) params.append('max_distance', filters.max_distance.toString())
if (filters.expires_before) params.append('expires_before', filters.expires_before.toString())
if (filters.page) params.append('page', filters.page.toString())
if (filters.page_size) params.append('page_size', filters.page_size.toString())
const response = await apiClient.get(`/v1/products/search?${params.toString()}`)
return response.data
}
}

View file

@ -1,12 +1,37 @@
export interface Product {
id: string
seller_id: string
name: string
activeIngredient: string
lab: string
category: string
description: string
batch: string
expiry: string
vendorId: string
vendorName: string
price: number
expires_at: string // ISO date
price_cents: number
stock: number
created_at: string
updated_at: string
}
export interface ProductWithDistance extends Product {
distance_km: number
tenant_city: string
tenant_state: string
}
export interface ProductSearchFilters {
search?: string
min_price?: number
max_price?: number
max_distance?: number
expires_before?: number
lat: number
lng: number
page?: number
page_size?: number
}
export interface ProductSearchPage {
products: ProductWithDistance[]
total: number
page: number
page_size: number
}