chore: refactor backend config, unignore .env, update config loading
This commit is contained in:
parent
4bb848788f
commit
25cee3911c
15 changed files with 480 additions and 114 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -12,9 +12,7 @@ Thumbs.db
|
||||||
.Trashes
|
.Trashes
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
|
||||||
21
backend/.env
Normal file
21
backend/.env
Normal 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
3
backend/.gitignore
vendored
|
|
@ -27,8 +27,7 @@ dist/
|
||||||
*.a
|
*.a
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,45 @@ func main() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
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
|
// Generate 400 tenants
|
||||||
tenants := generateTenants(rng, 400)
|
tenants := generateTenants(rng, 400)
|
||||||
log.Printf("Generated %d tenants", len(tenants))
|
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(99), rng.Intn(999), rng.Intn(999),
|
||||||
rng.Intn(9999)+1, rng.Intn(99))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ type Config struct {
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
AppName: getEnv("APP_NAME", "saveinmed-performance-core"),
|
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"),
|
DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/saveinmed?sslmode=disable"),
|
||||||
MaxOpenConns: getEnvInt("DB_MAX_OPEN_CONNS", 15),
|
MaxOpenConns: getEnvInt("DB_MAX_OPEN_CONNS", 15),
|
||||||
MaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 5),
|
MaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 5),
|
||||||
|
|
|
||||||
50
marketplace/package-lock.json
generated
50
marketplace/package-lock.json
generated
|
|
@ -9,9 +9,12 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mercadopago/sdk-react": "^1.0.6",
|
"@mercadopago/sdk-react": "^1.0.6",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"axios": "^1.7.4",
|
"axios": "^1.7.4",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"react-window": "^1.8.10",
|
"react-window": "^1.8.10",
|
||||||
"zustand": "^4.5.5"
|
"zustand": "^4.5.5"
|
||||||
|
|
@ -1093,6 +1096,17 @@
|
||||||
"node": ">= 8"
|
"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": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.1",
|
"version": "1.23.1",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz",
|
||||||
|
|
@ -1591,6 +1605,21 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
|
|
@ -2818,6 +2847,13 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
|
|
@ -3360,6 +3396,20 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,12 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mercadopago/sdk-react": "^1.0.6",
|
"@mercadopago/sdk-react": "^1.0.6",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"axios": "^1.7.4",
|
"axios": "^1.7.4",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"react-window": "^1.8.10",
|
"react-window": "^1.8.10",
|
||||||
"zustand": "^4.5.5"
|
"zustand": "^4.5.5"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { LoginPage } from './pages/Login'
|
import { LoginPage } from './pages/Login'
|
||||||
|
import ProductSearch from './pages/ProductSearch'
|
||||||
import { DashboardPage } from './pages/Dashboard'
|
import { DashboardPage } from './pages/Dashboard'
|
||||||
import { CartPage } from './pages/Cart'
|
import { CartPage } from './pages/Cart'
|
||||||
import { CheckoutPage } from './pages/Checkout'
|
import { CheckoutPage } from './pages/Checkout'
|
||||||
|
|
@ -78,7 +79,8 @@ function App() {
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="*" element={<Navigate to="/dashboard" />} />
|
<Route path="/search" element={<ProductSearch />} />
|
||||||
|
<Route path="*" element={<Navigate to="/search" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
marketplace/src/components/LocationPicker.tsx
Normal file
57
marketplace/src/components/LocationPicker.tsx
Normal 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='© <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>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
marketplace/src/components/ProductCard.tsx
Normal file
53
marketplace/src/components/ProductCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
marketplace/src/hooks/useDebounce.ts
Normal file
17
marketplace/src/hooks/useDebounce.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -1,25 +1,22 @@
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { FixedSizeList as List, ListChildComponentProps } from 'react-window'
|
import { FixedSizeList as List, ListChildComponentProps } from 'react-window'
|
||||||
import { usePersistentFilters, defaultFilters } from '../hooks/usePersistentFilters'
|
|
||||||
import { Product } from '../types/product'
|
import { Product } from '../types/product'
|
||||||
import { useCartStore } from '../stores/cartStore'
|
import { useCartStore } from '../stores/cartStore'
|
||||||
import { Shell } from '../layouts/Shell'
|
import { Shell } from '../layouts/Shell'
|
||||||
|
|
||||||
|
// Mock data updated to match new Product interface
|
||||||
const mockProducts: Product[] = Array.from({ length: 500 }).map((_, idx) => {
|
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 {
|
return {
|
||||||
id: `prod-${idx + 1}`,
|
id: `prod-${idx + 1}`,
|
||||||
|
seller_id: `seller-${idx % 4}`,
|
||||||
name: `Produto ${idx + 1}`,
|
name: `Produto ${idx + 1}`,
|
||||||
activeIngredient: actives[idx % actives.length],
|
description: `Descrição do produto ${idx + 1}`,
|
||||||
lab,
|
|
||||||
category: categories[idx % categories.length],
|
|
||||||
batch: `L${1000 + idx}`,
|
batch: `L${1000 + idx}`,
|
||||||
expiry: `202${(idx % 4) + 5}-0${(idx % 9) + 1}`,
|
expires_at: `202${(idx % 4) + 5}-0${(idx % 9) + 1}-01`,
|
||||||
vendorId: `vendor-${idx % 4}`,
|
price_cents: 1000 + (idx % 2000),
|
||||||
vendorName: ['Distribuidora Norte', 'Distribuidora Sul', 'FastMed', 'HealthPro'][idx % 4],
|
stock: 100 + idx,
|
||||||
price: 10 + (idx % 20)
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -30,29 +27,20 @@ function DenseRow({ index, style, data }: ListChildComponentProps<Product[]>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={style}
|
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 font-semibold text-gray-800">{product.name}</div>
|
||||||
<div className="truncate text-gray-700">{product.activeIngredient}</div>
|
<div className="truncate text-gray-700">{product.description}</div>
|
||||||
<div className="truncate text-gray-700">{product.lab}</div>
|
|
||||||
<div className="truncate text-gray-700">{product.batch}</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.expires_at}</div>
|
||||||
<div className="truncate text-gray-700">{product.vendorName}</div>
|
<div className="text-right font-semibold text-medicalBlue">R$ {(product.price_cents / 100).toFixed(2)}</div>
|
||||||
<div className="text-right font-semibold text-medicalBlue">R$ {product.price.toFixed(2)}</div>
|
|
||||||
<div className="col-span-2 flex items-center justify-end gap-2">
|
<div className="col-span-2 flex items-center justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
className="rounded bg-healthGreen px-3 py-1 text-xs font-semibold text-white shadow-sm hover:opacity-90"
|
className="rounded bg-healthGreen px-3 py-1 text-xs font-semibold text-white shadow-sm hover:opacity-90"
|
||||||
onClick={() => addItem({
|
onClick={() => addItem({
|
||||||
id: product.id,
|
...product,
|
||||||
name: product.name,
|
price: product.price_cents / 100 // Adapter for cart store if needed
|
||||||
activeIngredient: product.activeIngredient,
|
} as any)}
|
||||||
lab: product.lab,
|
|
||||||
batch: product.batch,
|
|
||||||
expiry: product.expiry,
|
|
||||||
vendorId: product.vendorId,
|
|
||||||
vendorName: product.vendorName,
|
|
||||||
unitPrice: product.price
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
Adicionar
|
Adicionar
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -62,101 +50,35 @@ function DenseRow({ index, style, data }: ListChildComponentProps<Product[]>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { filters, setFilters } = usePersistentFilters()
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return mockProducts.filter((p) => {
|
return mockProducts.filter((p) => {
|
||||||
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase())
|
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase())
|
||||||
const matchesActive = filters.activeIngredient
|
return matchesSearch
|
||||||
? 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
|
|
||||||
})
|
})
|
||||||
}, [filters.activeIngredient, filters.category, filters.lab, search])
|
}, [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)))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell>
|
<Shell>
|
||||||
<div className="mb-4 flex flex-col gap-4">
|
<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="grid grid-cols-1 gap-3 rounded-lg bg-white p-4 shadow-sm">
|
||||||
<div className="lg:col-span-2">
|
<div>
|
||||||
<p className="text-sm font-semibold text-gray-700">Busca rápida</p>
|
<p className="text-sm font-semibold text-gray-700">Busca rápida</p>
|
||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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"
|
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>
|
</div>
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-200 shadow-inner">
|
<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>Nome</span>
|
||||||
<span>Princípio Ativo</span>
|
<span>Descrição</span>
|
||||||
<span>Laboratório</span>
|
|
||||||
<span>Lote</span>
|
<span>Lote</span>
|
||||||
<span>Validade</span>
|
<span>Validade</span>
|
||||||
<span>Distribuidora</span>
|
|
||||||
<span className="text-right">Preço</span>
|
<span className="text-right">Preço</span>
|
||||||
<span className="col-span-2 text-right">Ações</span>
|
<span className="col-span-2 text-right">Ações</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
149
marketplace/src/pages/ProductSearch.tsx
Normal file
149
marketplace/src/pages/ProductSearch.tsx
Normal 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
|
||||||
24
marketplace/src/services/productService.ts
Normal file
24
marketplace/src/services/productService.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,37 @@
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id: string
|
id: string
|
||||||
|
seller_id: string
|
||||||
name: string
|
name: string
|
||||||
activeIngredient: string
|
description: string
|
||||||
lab: string
|
|
||||||
category: string
|
|
||||||
batch: string
|
batch: string
|
||||||
expiry: string
|
expires_at: string // ISO date
|
||||||
vendorId: string
|
price_cents: number
|
||||||
vendorName: string
|
stock: number
|
||||||
price: 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue