feat(web): redesign login page, fix search bug and enhance seeder

This commit is contained in:
Tiago Yamamoto 2025-12-23 18:34:46 -03:00
parent baa60c0d9b
commit 73ad7296ca
4 changed files with 398 additions and 135 deletions

View file

@ -73,7 +73,7 @@ func seedData(ctx context.Context, db *sqlx.DB, cfg config.Config) {
Email: cfg.AdminEmail, Email: cfg.AdminEmail,
}, cfg.AdminPassword, cfg.PasswordPepper) }, cfg.AdminPassword, cfg.PasswordPepper)
// 2. Distributors (Sellers) // 2. Distributors (Sellers - SP Center)
distributor1ID := uuid.Must(uuid.NewV7()) distributor1ID := uuid.Must(uuid.NewV7())
createCompany(ctx, db, &domain.Company{ createCompany(ctx, db, &domain.Company{
ID: distributor1ID, ID: distributor1ID,
@ -92,7 +92,28 @@ func seedData(ctx context.Context, db *sqlx.DB, cfg config.Config) {
Username: "distribuidora", Username: "distribuidora",
Email: "distribuidora@saveinmed.com", Email: "distribuidora@saveinmed.com",
}, "123456", cfg.PasswordPepper) }, "123456", cfg.PasswordPepper)
createShippingSettings(ctx, db, distributor1ID) createShippingSettings(ctx, db, distributor1ID, -23.55052, -46.633308, "Rua da Distribuidora, 1000")
// 2b. Second Distributor (Sellers - SP East Zone/Mooca)
distributor2ID := uuid.Must(uuid.NewV7())
createCompany(ctx, db, &domain.Company{
ID: distributor2ID,
CNPJ: "55555555000555",
CorporateName: "Distribuidora Leste Ltda",
Category: "distribuidora",
LicenseNumber: "DIST-002",
IsVerified: true,
Latitude: -23.55952,
Longitude: -46.593308,
})
createUser(ctx, db, &domain.User{
CompanyID: distributor2ID,
Role: "Owner",
Name: "Gerente Leste",
Username: "distribuidora_leste",
Email: "leste@saveinmed.com",
}, "123456", cfg.PasswordPepper)
createShippingSettings(ctx, db, distributor2ID, -23.55952, -46.593308, "Rua da Mooca, 500")
// 3. Pharmacies (Buyers) // 3. Pharmacies (Buyers)
pharmacy1ID := uuid.Must(uuid.NewV7()) pharmacy1ID := uuid.Must(uuid.NewV7())
@ -114,46 +135,118 @@ func seedData(ctx context.Context, db *sqlx.DB, cfg config.Config) {
Email: "farmacia@saveinmed.com", Email: "farmacia@saveinmed.com",
}, "123456", cfg.PasswordPepper) }, "123456", cfg.PasswordPepper)
pharmacy2ID := uuid.Must(uuid.NewV7())
createCompany(ctx, db, &domain.Company{
ID: pharmacy2ID,
CNPJ: "33333333000133",
CorporateName: "Drogarias Tiete",
Category: "farmacia",
LicenseNumber: "FARM-002",
IsVerified: true,
Latitude: -23.51052,
Longitude: -46.613308,
})
createUser(ctx, db, &domain.User{
CompanyID: pharmacy2ID,
Role: "Owner",
Name: "Gerente Tiete",
Username: "farmacia2",
Email: "tiete@saveinmed.com",
}, "123456", cfg.PasswordPepper)
// 4. Products // 4. Products
products := []struct { // List of diverse products
commonMeds := []struct {
Name string Name string
Price int64 Price int64 // Base price
Stock int64
}{ }{
{"Dipirona 500mg", 500, 1000}, {"Dipirona Sódica 500mg", 450},
{"Paracetamol 750mg", 750, 1000}, {"Paracetamol 750mg", 600},
{"Ibuprofeno 600mg", 1200, 500}, {"Ibuprofeno 400mg", 1100},
{"Amoxicilina 500mg", 2500, 300}, {"Amoxicilina 500mg", 2200},
{"Omeprazol 20mg", 1500, 800}, {"Omeprazol 20mg", 1400},
{"Simeticona 40mg", 700},
{"Dorflex 36cp", 1850},
{"Neosaldina 30dg", 2100},
{"Torsilax 30cp", 1200},
{"Cimegripe 20caps", 1300},
{"Losartana Potássica 50mg", 800},
{"Atenolol 25mg", 950},
{"Metformina 850mg", 750},
{"Sildenafila 50mg", 1500},
{"Azitromicina 500mg", 2800},
} }
var productIDs []uuid.UUID var productIDs []uuid.UUID
for _, p := range products { // Seed products for Dist 1
for i, p := range commonMeds {
id := uuid.Must(uuid.NewV7()) id := uuid.Must(uuid.NewV7())
expiry := time.Now().AddDate(1, 0, 0) expiry := time.Now().AddDate(1, 0, 0)
// Vary price slightly
finalPrice := p.Price + int64(i*10) - 50
if finalPrice < 100 {
finalPrice = 100
}
createProduct(ctx, db, &domain.Product{ createProduct(ctx, db, &domain.Product{
ID: id, ID: id,
SellerID: distributor1ID, SellerID: distributor1ID,
Name: p.Name, Name: p.Name,
Description: "Medicamento genérico de alta qualidade", Description: "Medicamento genérico de alta qualidade (Nacional)",
Batch: "BATCH-" + id.String()[:8], Batch: "BATCH-NAC-" + id.String()[:4],
ExpiresAt: expiry, ExpiresAt: expiry,
PriceCents: p.Price, PriceCents: finalPrice,
Stock: p.Stock, Stock: 1000 + int64(i*100),
})
// Keep first 5 for orders
if i < 5 {
productIDs = append(productIDs, id)
}
}
// Seed products for Dist 2 (Leste) - Only some of them, different prices
for i, p := range commonMeds {
if i%2 == 0 {
continue
} // Skip half
id := uuid.Must(uuid.NewV7())
expiry := time.Now().AddDate(0, 6, 0) // Shorter expiry
// Cheaper but fewer stock
finalPrice := p.Price - 100
if finalPrice < 100 {
finalPrice = 100
}
createProduct(ctx, db, &domain.Product{
ID: id,
SellerID: distributor2ID,
Name: p.Name,
Description: "Distribuição exclusiva ZL",
Batch: "BATCH-ZL-" + id.String()[:4],
ExpiresAt: expiry,
PriceCents: finalPrice,
Stock: 50 + int64(i*10),
}) })
productIDs = append(productIDs, id)
} }
// 5. Orders // 5. Orders
// Create an order from Pharmacy to Distributor // Order 1: Pharmacy 1 buying from Dist 1
orderID := uuid.Must(uuid.NewV7()) orderID := uuid.Must(uuid.NewV7())
totalCents := int64(0) totalCents := int64(0)
// Items // Items
qty := int64(10) qty := int64(10)
price := products[0].Price priceItem := productIDs[0] // Dipirona from Dist 1
itemTotal := price * qty // We need to fetch price ideally but we know logic... let's just reuse base logic approx or fetch from struct above
// Simulating price:
unitPrice := commonMeds[0].Price - 50 // Same logic as above: p.Price + 0*10 - 50
itemTotal := unitPrice * qty
totalCents += itemTotal totalCents += itemTotal
createOrder(ctx, db, &domain.Order{ createOrder(ctx, db, &domain.Order{
@ -169,13 +262,25 @@ func seedData(ctx context.Context, db *sqlx.DB, cfg config.Config) {
createOrderItem(ctx, db, &domain.OrderItem{ createOrderItem(ctx, db, &domain.OrderItem{
ID: uuid.Must(uuid.NewV7()), ID: uuid.Must(uuid.NewV7()),
OrderID: orderID, OrderID: orderID,
ProductID: productIDs[0], ProductID: priceItem,
Quantity: qty, Quantity: qty,
UnitCents: price, UnitCents: unitPrice,
Batch: "BATCH-" + productIDs[0].String()[:8], Batch: "BATCH-NAC-" + priceItem.String()[:4],
ExpiresAt: time.Now().AddDate(1, 0, 0), ExpiresAt: time.Now().AddDate(1, 0, 0),
}) })
// Order 2: Pharmacy 2 buying from Dist 1 (Pending)
order2ID := uuid.Must(uuid.NewV7())
createOrder(ctx, db, &domain.Order{
ID: order2ID,
BuyerID: pharmacy2ID,
SellerID: distributor1ID,
Status: "Pendente",
TotalCents: 5000,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
} }
func createCompany(ctx context.Context, db *sqlx.DB, c *domain.Company) { func createCompany(ctx context.Context, db *sqlx.DB, c *domain.Company) {
@ -221,7 +326,7 @@ func createProduct(ctx context.Context, db *sqlx.DB, p *domain.Product) {
} }
} }
func createShippingSettings(ctx context.Context, db *sqlx.DB, vendorID uuid.UUID) { func createShippingSettings(ctx context.Context, db *sqlx.DB, vendorID uuid.UUID, lat, lng float64, address string) {
now := time.Now().UTC() now := time.Now().UTC()
settings := &domain.ShippingSettings{ settings := &domain.ShippingSettings{
VendorID: vendorID, VendorID: vendorID,
@ -231,12 +336,12 @@ func createShippingSettings(ctx context.Context, db *sqlx.DB, vendorID uuid.UUID
MinFeeCents: 1000, // R$ 10.00 MinFeeCents: 1000, // R$ 10.00
FreeShippingThresholdCents: nil, FreeShippingThresholdCents: nil,
PickupActive: true, PickupActive: true,
PickupAddress: "Rua da Distribuidora, 1000", PickupAddress: address,
PickupHours: "Seg-Sex 08-18h", PickupHours: "Seg-Sex 08-18h",
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
Latitude: -23.55052, Latitude: lat,
Longitude: -46.633308, Longitude: lng,
} }
_, err := db.NamedExecContext(ctx, ` _, err := db.NamedExecContext(ctx, `

View file

@ -4,17 +4,18 @@ import { useAuth, UserRole } from '../context/AuthContext'
import { authService } from '../services/auth' import { authService } from '../services/auth'
import { logger } from '../utils/logger' import { logger } from '../utils/logger'
import { decodeJwtPayload } from '../utils/jwt' import { decodeJwtPayload } from '../utils/jwt'
import logoImg from '../assets/logo.png' // Ensure logo import is handled
// Eye icon components for password visibility toggle // Eye icon components for password visibility toggle
const EyeIcon = () => ( const EyeIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5 text-gray-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg> </svg>
) )
const EyeSlashIcon = () => ( const EyeSlashIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5 text-gray-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" /> <path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg> </svg>
) )
@ -26,25 +27,17 @@ export function LoginPage() {
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login')
const resolveRole = (role?: string): UserRole => { const resolveRole = (role?: string): UserRole => {
logger.info('🔐 [Login] Resolving role:', role) logger.info('🔐 [Login] Resolving role:', role)
switch (role?.toLowerCase()) { switch (role?.toLowerCase()) {
case 'admin': case 'admin': return 'admin'
return 'admin' case 'dono': return 'owner'
case 'dono': case 'colaborador': return 'employee'
return 'owner' case 'entregador': return 'delivery'
case 'colaborador': case 'customer': return 'customer'
return 'employee' case 'seller': default: return 'seller'
case 'entregador':
return 'delivery'
case 'customer':
return 'customer'
case 'seller': // keep legacy
default:
// Default to seller/owner or log warning?
logger.warn('⚠️ [Login] Unknown role, defaulting to seller:', role)
return 'seller'
} }
} }
@ -53,37 +46,20 @@ export function LoginPage() {
setLoading(true) setLoading(true)
setErrorMessage(null) setErrorMessage(null)
logger.info('🔐 [Login] Attempting login with username:', username)
try { try {
logger.info('🔐 [Login] Calling authService.login...')
const response = await authService.login({ username, password }) const response = await authService.login({ username, password })
logger.info('🔐 [Login] Response received:', response)
const { token } = response const { token } = response
logger.debug('🔐 [Login] Token extracted:', token ? `${token.substring(0, 50)}...` : 'NULL/UNDEFINED') if (!token) throw new Error('Resposta de login inválida. Verifique o usuário e a senha.')
if (!token) {
logger.error('🔐 [Login] ERROR: Token is null or undefined!')
throw new Error('Resposta de login inválida. Verifique o usuário e a senha.')
}
const payload = decodeJwtPayload<{ role?: string, sub: string, company_id?: string }>(token) const payload = decodeJwtPayload<{ role?: string, sub: string, company_id?: string }>(token)
logger.debug('🔐 [Login] JWT payload decoded:', payload)
const role = resolveRole(payload?.role) const role = resolveRole(payload?.role)
logger.info('🔐 [Login] Role resolved:', role)
login(token, role, username, payload?.sub || '', payload?.company_id, undefined, username) login(token, role, username, payload?.sub || '', payload?.company_id, undefined, username)
logger.info('🔐 [Login] Login successful!')
} catch (error) { } catch (error) {
logger.error('🔐 [Login] ERROR caught:', error)
const fallback = 'Não foi possível autenticar. Verifique suas credenciais.' const fallback = 'Não foi possível autenticar. Verifique suas credenciais.'
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
logger.error('🔐 [Login] Axios error response:', error.response?.data)
setErrorMessage(error.response?.data?.error ?? fallback) setErrorMessage(error.response?.data?.error ?? fallback)
} else if (error instanceof Error) { } else if (error instanceof Error) {
logger.error('🔐 [Login] Error message:', error.message)
setErrorMessage(error.message) setErrorMessage(error.message)
} else { } else {
setErrorMessage(fallback) setErrorMessage(fallback)
@ -94,63 +70,124 @@ export function LoginPage() {
} }
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gray-100"> <div className="flex min-h-screen items-center justify-center bg-gray-100 p-4">
<form <div className="w-full max-w-[400px] overflow-hidden rounded-2xl bg-white shadow-xl">
onSubmit={onSubmit} {/* Blue Header with Logo */}
className="w-full max-w-md space-y-4 rounded-lg bg-white p-8 shadow-lg" <div className="flex flex-col items-center bg-blue-600 pb-8 pt-10 text-white">
> <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/10 backdrop-blur-sm">
<h1 className="text-2xl font-bold text-medicalBlue">Acesso ao Marketplace</h1> <img src={logoImg} alt="Logo" className="h-10 w-auto brightness-0 invert" />
<p className="text-sm text-gray-600">Informe suas credenciais para acessar o marketplace.</p>
{errorMessage && (
<div className="rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{errorMessage}
</div> </div>
)} <h1 className="text-2xl font-bold">SaveInMed</h1>
<div className="space-y-2"> <p className="text-blue-100 text-sm">Plataforma B2B de Medicamentos</p>
<label className="text-sm font-medium text-gray-700" htmlFor="username">
Usuário
</label>
<input
id="username"
name="username"
autoComplete="username"
className="w-full rounded border border-gray-200 px-3 py-2"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700" htmlFor="password"> {/* Tabs */}
Senha <div className="flex border-b border-gray-100 p-2 bg-white">
</label> <button
<div className="relative"> onClick={() => setActiveTab('login')}
<input className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors ${activeTab === 'login' ? 'bg-white text-blue-600 shadow-sm border border-gray-100' : 'text-gray-500 hover:text-gray-700'
id="password" }`}
name="password" >
type={showPassword ? "text" : "password"} Entrar
autoComplete="current-password" </button>
className="w-full rounded border border-gray-200 px-3 py-2 pr-10" <button
value={password} onClick={() => setActiveTab('register')}
onChange={(e) => setPassword(e.target.value)} className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors ${activeTab === 'register' ? 'bg-white text-blue-600 shadow-sm border border-gray-100' : 'text-gray-500 hover:text-gray-700'
/> }`}
>
Cadastrar
</button>
</div>
{/* Login Form */}
{activeTab === 'login' ? (
<form onSubmit={onSubmit} className="p-8 space-y-5">
{errorMessage && (
<div className="flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-600 border border-red-100">
<svg className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>credenciais inválidas, tente novamente</span>
</div>
)}
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700 ml-1">Email ou Usuário</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-gray-400">
<span className="text-lg">@</span>
</div>
<input
id="username"
name="username"
autoComplete="username"
placeholder="seu@email.com"
className="w-full rounded-xl border border-gray-200 py-2.5 pl-10 pr-3 text-gray-800 placeholder-gray-400 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 transition-all outline-none"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700 ml-1">Senha</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-gray-400">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
</div>
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
placeholder="••••••••"
className="w-full rounded-xl border border-gray-200 py-2.5 pl-10 pr-10 text-gray-800 placeholder-gray-400 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 transition-all outline-none"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none transition-colors"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</div>
</div>
<div className="pt-2">
<button
type="submit"
className="w-full rounded-xl bg-slate-600 py-3 font-semibold text-white shadow-lg shadow-slate-200 hover:bg-slate-700 hover:shadow-xl active:translate-y-0.5 focus:ring-2 focus:ring-slate-200 transition-all disabled:opacity-70 flex items-center justify-center gap-2"
disabled={loading}
>
{loading ? (
<>
<svg className="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
Entrando...
</>
) : (
<>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" /></svg>
Entrar
</>
)}
</button>
</div>
</form>
) : (
<div className="p-8 text-center text-gray-500 py-12">
<p>Funcionalidade de cadastro em breve.</p>
<button <button
type="button" onClick={() => setActiveTab('login')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 focus:outline-none" className="mt-4 text-blue-600 hover:underline text-sm"
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? "Ocultar senha" : "Mostrar senha"}
> >
{showPassword ? <EyeSlashIcon /> : <EyeIcon />} Voltar para login
</button> </button>
</div> </div>
</div> )}
<button
type="submit" </div>
className="w-full rounded bg-healthGreen px-4 py-2 font-semibold text-white disabled:cursor-not-allowed disabled:opacity-70"
disabled={loading}
>
{loading ? 'Entrando...' : 'Entrar'}
</button>
</form>
</div> </div>
) )
} }

View file

@ -1,34 +1,150 @@
import { useState } from 'react'
import { Shell } from '../layouts/Shell' import { Shell } from '../layouts/Shell'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { adminService } from '../services/adminService'
export function MyProfilePage() { export function MyProfilePage() {
const { user } = useAuth() const { user, login } = useAuth()
const [isEditing, setIsEditing] = useState(false)
const [loading, setLoading] = useState(false)
const [formData, setFormData] = useState({
name: user?.name || '',
email: user?.email || '',
username: user?.username || ''
})
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value })
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!user) return
setLoading(true)
try {
const updatedUser = await adminService.updateUser(user.id, {
name: formData.name,
email: formData.email,
username: formData.username
})
// Update local auth context
login(
user.token,
user.role,
updatedUser.name,
updatedUser.id,
updatedUser.company_id,
updatedUser.email,
updatedUser.username
)
setIsEditing(false)
alert('Perfil atualizado com sucesso!')
} catch (err) {
console.error('Error updating profile', err)
alert('Erro ao atualizar perfil. Tente novamente.')
} finally {
setLoading(false)
}
}
return ( return (
<Shell> <Shell>
<div className="space-y-6 rounded-lg bg-white p-6 shadow-sm"> <div className="space-y-6 rounded-lg bg-white p-6 shadow-sm max-w-4xl mx-auto">
<div> <div className="flex justify-between items-center">
<h1 className="text-xl font-semibold text-medicalBlue">Meu Perfil</h1> <div>
<p className="text-sm text-gray-600">Acompanhe as informações básicas da sua conta.</p> <h1 className="text-xl font-semibold text-medicalBlue">Meu Perfil</h1>
<p className="text-sm text-gray-600">Acompanhe as informações básicas da sua conta.</p>
</div>
{!isEditing ? (
<button
onClick={() => setIsEditing(true)}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition-colors"
>
Editar Dados
</button>
) : (
<button
onClick={() => {
setIsEditing(false)
setFormData({
name: user?.name || '',
email: user?.email || '',
username: user?.username || ''
})
}}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
>
Cancelar
</button>
)}
</div> </div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-md border border-gray-200 p-4"> <form onSubmit={handleSave} className="grid gap-6">
<p className="text-xs uppercase text-gray-400">Nome</p> <div className="grid gap-4 sm:grid-cols-2">
<p className="text-sm font-semibold text-gray-800">{user?.name ?? 'Não informado'}</p> <div className="rounded-md border border-gray-200 p-4 relative">
<label className="text-xs uppercase text-gray-400 block mb-1">Nome</label>
{isEditing ? (
<input
name="name"
value={formData.name}
onChange={handleChange}
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-semibold text-gray-800"
/>
) : (
<p className="text-sm font-semibold text-gray-800">{user?.name ?? 'Não informado'}</p>
)}
</div>
<div className="rounded-md border border-gray-200 p-4 bg-gray-50">
<p className="text-xs uppercase text-gray-400 mb-1">Perfil</p>
<p className="text-sm font-semibold text-gray-800">{user?.role ?? 'Não informado'}</p>
{isEditing && <span className="text-[10px] text-gray-500 absolute top-2 right-2">(Não editável)</span>}
</div>
<div className="rounded-md border border-gray-200 p-4">
<label className="text-xs uppercase text-gray-400 block mb-1">E-mail</label>
{isEditing ? (
<input
name="email"
type="email"
value={formData.email}
onChange={handleChange}
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-semibold text-gray-800"
/>
) : (
<p className="text-sm font-semibold text-gray-800">{user?.email ?? 'Não informado'}</p>
)}
</div>
<div className="rounded-md border border-gray-200 p-4">
<label className="text-xs uppercase text-gray-400 block mb-1">Usuário</label>
{isEditing ? (
<input
name="username"
value={formData.username}
onChange={handleChange}
className="w-full p-1 border-b border-blue-500 focus:outline-none text-sm font-semibold text-gray-800"
/>
) : (
<p className="text-sm font-semibold text-gray-800">{user?.username ?? 'Não informado'}</p>
)}
</div>
</div> </div>
<div className="rounded-md border border-gray-200 p-4">
<p className="text-xs uppercase text-gray-400">Perfil</p> {isEditing && (
<p className="text-sm font-semibold text-gray-800">{user?.role ?? 'Não informado'}</p> <div className="flex justify-end pt-4 border-t">
</div> <button
<div className="rounded-md border border-gray-200 p-4"> type="submit"
<p className="text-xs uppercase text-gray-400">E-mail</p> disabled={loading}
<p className="text-sm font-semibold text-gray-800">{user?.email ?? 'Não informado'}</p> className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2 rounded-lg font-medium shadow-sm disabled:opacity-50 transition-all flex items-center gap-2"
</div> >
<div className="rounded-md border border-gray-200 p-4"> {loading ? 'Salvando...' : 'Salvar Alterações'}
<p className="text-xs uppercase text-gray-400">Usuário</p> </button>
<p className="text-sm font-semibold text-gray-800">{user?.username ?? 'Não informado'}</p> </div>
</div> )}
</div> </form>
</div> </div>
</Shell> </Shell>
) )

View file

@ -119,6 +119,11 @@ const ProductSearch = () => {
} }
}, [products, user?.id, sortBy]) }, [products, user?.id, sortBy])
// Calculate total visible offers after filtering
const visibleOffers = useMemo(() => {
return groupedProducts.reduce((acc, group) => acc + group.offerCount, 0)
}, [groupedProducts])
const handleLocationSelect = (newLat: number, newLng: number) => { const handleLocationSelect = (newLat: number, newLng: number) => {
setLat(newLat) setLat(newLat)
setLng(newLng) setLng(newLng)
@ -261,7 +266,7 @@ 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...' : `${groupedProducts.length} medicamentos (${total} ofertas)`} {loading ? 'Buscando...' : `${groupedProducts.length} medicamentos (${visibleOffers} ofertas)`}
</h2> </h2>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span className="text-gray-500">Ordenar por:</span> <span className="text-gray-500">Ordenar por:</span>