From 25cee3911c58c9666db0e668a33b963faf2bf58c Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Sat, 20 Dec 2025 10:10:55 -0300 Subject: [PATCH] chore: refactor backend config, unignore .env, update config loading --- .gitignore | 4 +- backend/.env | 21 +++ backend/.gitignore | 3 +- backend/cmd/seeder/main.go | 46 ++++++ backend/internal/config/config.go | 2 +- marketplace/package-lock.json | 50 ++++++ marketplace/package.json | 3 + marketplace/src/App.tsx | 4 +- marketplace/src/components/LocationPicker.tsx | 57 +++++++ marketplace/src/components/ProductCard.tsx | 53 +++++++ marketplace/src/hooks/useDebounce.ts | 17 ++ marketplace/src/pages/Dashboard.tsx | 122 +++----------- marketplace/src/pages/ProductSearch.tsx | 149 ++++++++++++++++++ marketplace/src/services/productService.ts | 24 +++ marketplace/src/types/product.ts | 39 ++++- 15 files changed, 480 insertions(+), 114 deletions(-) create mode 100644 backend/.env create mode 100644 marketplace/src/components/LocationPicker.tsx create mode 100644 marketplace/src/components/ProductCard.tsx create mode 100644 marketplace/src/hooks/useDebounce.ts create mode 100644 marketplace/src/pages/ProductSearch.tsx create mode 100644 marketplace/src/services/productService.ts diff --git a/.gitignore b/.gitignore index 32887a5..ccf8513 100644 --- a/.gitignore +++ b/.gitignore @@ -12,9 +12,7 @@ Thumbs.db .Trashes # Environment variables -.env -.env.local -.env.*.local + # Logs *.log diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..f6e3a89 --- /dev/null +++ b/backend/.env @@ -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=* diff --git a/backend/.gitignore b/backend/.gitignore index 28a736a..1368c5d 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -27,8 +27,7 @@ dist/ *.a # Environment variables -.env -.env.local + # Logs *.log diff --git a/backend/cmd/seeder/main.go b/backend/cmd/seeder/main.go index 47be53c..638f6a9 100644 --- a/backend/cmd/seeder/main.go +++ b/backend/cmd/seeder/main.go @@ -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) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a82d712..d8a8da9 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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), diff --git a/marketplace/package-lock.json b/marketplace/package-lock.json index a9216f8..897911b 100644 --- a/marketplace/package-lock.json +++ b/marketplace/package-lock.json @@ -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", diff --git a/marketplace/package.json b/marketplace/package.json index af0f9da..0810ca8 100644 --- a/marketplace/package.json +++ b/marketplace/package.json @@ -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" diff --git a/marketplace/src/App.tsx b/marketplace/src/App.tsx index cc0af48..fb14b4f 100644 --- a/marketplace/src/App.tsx +++ b/marketplace/src/App.tsx @@ -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() { } /> - } /> + } /> + } /> ) } diff --git a/marketplace/src/components/LocationPicker.tsx b/marketplace/src/components/LocationPicker.tsx new file mode 100644 index 0000000..fe47abc --- /dev/null +++ b/marketplace/src/components/LocationPicker.tsx @@ -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(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 : ( + + ) +} + +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 ( +
+ + + + {(initialLat && initialLng) && } + +
+ ) +} diff --git a/marketplace/src/components/ProductCard.tsx b/marketplace/src/components/ProductCard.tsx new file mode 100644 index 0000000..efd4145 --- /dev/null +++ b/marketplace/src/components/ProductCard.tsx @@ -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 ( +
+
+
+

{product.name}

+

{product.description}

+
+ {isExpiringSoon && ( + + Vence em breve + + )} +
+ +
+
+ Lote: {product.batch} + Val: {new Date(product.expires_at).toLocaleDateString()} +
+ +
+ + + + + {product.distance_km} km • {product.tenant_city}/{product.tenant_state} +
+ +
+
+ R$ {(product.price_cents / 100).toFixed(2).replace('.', ',')} +
+ +
+
+
+ ) +} diff --git a/marketplace/src/hooks/useDebounce.ts b/marketplace/src/hooks/useDebounce.ts new file mode 100644 index 0000000..3354000 --- /dev/null +++ b/marketplace/src/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react' + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(handler) + } + }, [value, delay]) + + return debouncedValue +} diff --git a/marketplace/src/pages/Dashboard.tsx b/marketplace/src/pages/Dashboard.tsx index 01a376d..a9a98be 100644 --- a/marketplace/src/pages/Dashboard.tsx +++ b/marketplace/src/pages/Dashboard.tsx @@ -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) { return (
{product.name}
-
{product.activeIngredient}
-
{product.lab}
+
{product.description}
{product.batch}
-
{product.expiry}
-
{product.vendorName}
-
R$ {product.price.toFixed(2)}
+
{product.expires_at}
+
R$ {(product.price_cents / 100).toFixed(2)}
@@ -62,101 +50,35 @@ function DenseRow({ index, style, data }: ListChildComponentProps) { } 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 (
-
-
+
+

Busca rápida

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" /> -

Otimizada para milhares de SKUs com renderização virtualizada.

-
-
- - -
-
- - -
-
- - -
-
-
-
+
Nome - Princípio Ativo - Laboratório + Descrição Lote Validade - Distribuidora Preço Ações
diff --git a/marketplace/src/pages/ProductSearch.tsx b/marketplace/src/pages/ProductSearch.tsx new file mode 100644 index 0000000..da355ce --- /dev/null +++ b/marketplace/src/pages/ProductSearch.tsx @@ -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(-16.3285) + const [lng, setLng] = useState(-48.9534) + const [search, setSearch] = useState('') + const [maxDistance, setMaxDistance] = useState(10) + const [products, setProducts] = useState([]) + 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 ( +
+

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

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

+
+ Ordenado por: Vencimento (mais próximo) +
+
+ + {loading ? ( +
+ {[...Array(6)].map((_, i) => ( +
+ ))} +
+ ) : ( +
+ {products.map((product) => ( + + ))} + + {products.length === 0 && ( +
+

Nenhum produto encontrado nesta região.

+

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

+
+ )} +
+ )} +
+
+
+ ) +} + +export default ProductSearch diff --git a/marketplace/src/services/productService.ts b/marketplace/src/services/productService.ts new file mode 100644 index 0000000..9fc2660 --- /dev/null +++ b/marketplace/src/services/productService.ts @@ -0,0 +1,24 @@ +import { apiClient } from './apiClient' +import { ProductSearchFilters, ProductSearchPage } from '../types/product' + +export const productService = { + search: async (filters: ProductSearchFilters): Promise => { + // 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 + } +} diff --git a/marketplace/src/types/product.ts b/marketplace/src/types/product.ts index 267ff32..64c8f4f 100644 --- a/marketplace/src/types/product.ts +++ b/marketplace/src/types/product.ts @@ -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 }