feat: overhaul shipping module, add seeder, and improve order UI

This commit is contained in:
Tiago Yamamoto 2025-12-23 18:23:32 -03:00
parent 8a5ec57e9c
commit baa60c0d9b
20 changed files with 1222 additions and 270 deletions

276
backend/cmd/seeder/main.go Normal file
View file

@ -0,0 +1,276 @@
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/gofrs/uuid/v5"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
"github.com/saveinmed/backend-go/internal/config"
"github.com/saveinmed/backend-go/internal/domain"
)
func main() {
cfg := config.Load()
db, err := sqlx.Open("pgx", cfg.DatabaseURL)
if err != nil {
log.Fatalf("Failed to connect to DB: %v", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatalf("Failed to ping DB: %v", err)
}
ctx := context.Background()
log.Println("🧹 Cleaning database...")
cleanDB(ctx, db)
log.Println("🌱 Seeding data...")
seedData(ctx, db, cfg)
log.Println("✅ Seeding complete!")
}
func cleanDB(ctx context.Context, db *sqlx.DB) {
tables := []string{
"reviews", "shipments", "payment_preferences", "orders", "order_items",
"cart_items", "products", "companies", "users", "shipping_settings",
}
for _, table := range tables {
_, err := db.ExecContext(ctx, fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table))
if err != nil {
// Ignore error if table doesn't exist or is empty
log.Printf("Warning cleaning %s: %v", table, err)
}
}
}
func seedData(ctx context.Context, db *sqlx.DB, cfg config.Config) {
// 1. Seed Admin
adminCompanyID := uuid.Must(uuid.NewV7())
createCompany(ctx, db, &domain.Company{
ID: adminCompanyID,
CNPJ: "00000000000000",
CorporateName: "SaveInMed Admin",
Category: "admin",
LicenseNumber: "ADMIN",
IsVerified: true,
})
createUser(ctx, db, &domain.User{
CompanyID: adminCompanyID,
Role: "Admin",
Name: cfg.AdminName,
Username: cfg.AdminUsername,
Email: cfg.AdminEmail,
}, cfg.AdminPassword, cfg.PasswordPepper)
// 2. Distributors (Sellers)
distributor1ID := uuid.Must(uuid.NewV7())
createCompany(ctx, db, &domain.Company{
ID: distributor1ID,
CNPJ: "11111111000111",
CorporateName: "Distribuidora Nacional",
Category: "distribuidora",
LicenseNumber: "DIST-001",
IsVerified: true,
Latitude: -23.55052,
Longitude: -46.633308,
})
createUser(ctx, db, &domain.User{
CompanyID: distributor1ID,
Role: "Owner",
Name: "Dono da Distribuidora",
Username: "distribuidora",
Email: "distribuidora@saveinmed.com",
}, "123456", cfg.PasswordPepper)
createShippingSettings(ctx, db, distributor1ID)
// 3. Pharmacies (Buyers)
pharmacy1ID := uuid.Must(uuid.NewV7())
createCompany(ctx, db, &domain.Company{
ID: pharmacy1ID,
CNPJ: "22222222000122",
CorporateName: "Farmácia Central",
Category: "farmacia",
LicenseNumber: "FARM-001",
IsVerified: true,
Latitude: -23.56052,
Longitude: -46.643308,
})
createUser(ctx, db, &domain.User{
CompanyID: pharmacy1ID,
Role: "Owner",
Name: "Dono da Farmácia",
Username: "farmacia",
Email: "farmacia@saveinmed.com",
}, "123456", cfg.PasswordPepper)
// 4. Products
products := []struct {
Name string
Price int64
Stock int64
}{
{"Dipirona 500mg", 500, 1000},
{"Paracetamol 750mg", 750, 1000},
{"Ibuprofeno 600mg", 1200, 500},
{"Amoxicilina 500mg", 2500, 300},
{"Omeprazol 20mg", 1500, 800},
}
var productIDs []uuid.UUID
for _, p := range products {
id := uuid.Must(uuid.NewV7())
expiry := time.Now().AddDate(1, 0, 0)
createProduct(ctx, db, &domain.Product{
ID: id,
SellerID: distributor1ID,
Name: p.Name,
Description: "Medicamento genérico de alta qualidade",
Batch: "BATCH-" + id.String()[:8],
ExpiresAt: expiry,
PriceCents: p.Price,
Stock: p.Stock,
})
productIDs = append(productIDs, id)
}
// 5. Orders
// Create an order from Pharmacy to Distributor
orderID := uuid.Must(uuid.NewV7())
totalCents := int64(0)
// Items
qty := int64(10)
price := products[0].Price
itemTotal := price * qty
totalCents += itemTotal
createOrder(ctx, db, &domain.Order{
ID: orderID,
BuyerID: pharmacy1ID,
SellerID: distributor1ID,
Status: "Faturado", // Ready for "Confirmar Entrega" test
TotalCents: totalCents,
CreatedAt: time.Now().AddDate(0, 0, -2),
UpdatedAt: time.Now(),
})
createOrderItem(ctx, db, &domain.OrderItem{
ID: uuid.Must(uuid.NewV7()),
OrderID: orderID,
ProductID: productIDs[0],
Quantity: qty,
UnitCents: price,
Batch: "BATCH-" + productIDs[0].String()[:8],
ExpiresAt: time.Now().AddDate(1, 0, 0),
})
}
func createCompany(ctx context.Context, db *sqlx.DB, c *domain.Company) {
now := time.Now().UTC()
c.CreatedAt = now
c.UpdatedAt = now
_, err := db.NamedExecContext(ctx, `
INSERT INTO companies (id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, created_at, updated_at)
VALUES (:id, :cnpj, :corporate_name, :category, :license_number, :is_verified, :latitude, :longitude, :created_at, :updated_at)
`, c)
if err != nil {
log.Printf("Error creating company %s: %v", c.CorporateName, err)
}
}
func createUser(ctx context.Context, db *sqlx.DB, u *domain.User, password, pepper string) {
hashed, _ := bcrypt.GenerateFromPassword([]byte(password+pepper), bcrypt.DefaultCost)
u.ID = uuid.Must(uuid.NewV7())
u.PasswordHash = string(hashed)
u.CreatedAt = time.Now().UTC()
u.UpdatedAt = time.Now().UTC()
// Ensure email/username uniqueness is handled by DB constraint, usually we just insert
_, err := db.NamedExecContext(ctx, `
INSERT INTO users (id, company_id, role, name, username, email, password_hash, email_verified, created_at, updated_at)
VALUES (:id, :company_id, :role, :name, :username, :email, :password_hash, :email_verified, :created_at, :updated_at)
`, u)
if err != nil {
log.Printf("Error creating user %s: %v", u.Username, err)
}
}
func createProduct(ctx context.Context, db *sqlx.DB, p *domain.Product) {
now := time.Now().UTC()
p.CreatedAt = now
p.UpdatedAt = now
_, err := db.NamedExecContext(ctx, `
INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at)
VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock, :created_at, :updated_at)
`, p)
if err != nil {
log.Printf("Error creating product %s: %v", p.Name, err)
}
}
func createShippingSettings(ctx context.Context, db *sqlx.DB, vendorID uuid.UUID) {
now := time.Now().UTC()
settings := &domain.ShippingSettings{
VendorID: vendorID,
Active: true,
MaxRadiusKm: 50,
PricePerKmCents: 150, // R$ 1.50
MinFeeCents: 1000, // R$ 10.00
FreeShippingThresholdCents: nil,
PickupActive: true,
PickupAddress: "Rua da Distribuidora, 1000",
PickupHours: "Seg-Sex 08-18h",
CreatedAt: now,
UpdatedAt: now,
Latitude: -23.55052,
Longitude: -46.633308,
}
_, err := db.NamedExecContext(ctx, `
INSERT INTO shipping_settings (
vendor_id, active, max_radius_km, price_per_km_cents, min_fee_cents,
free_shipping_threshold_cents, pickup_active, pickup_address, pickup_hours,
latitude, longitude, created_at, updated_at
) VALUES (
:vendor_id, :active, :max_radius_km, :price_per_km_cents, :min_fee_cents,
:free_shipping_threshold_cents, :pickup_active, :pickup_address, :pickup_hours,
:latitude, :longitude, :created_at, :updated_at
)
`, settings)
if err != nil {
log.Printf("Error creating shipping settings: %v", err)
}
}
func createOrder(ctx context.Context, db *sqlx.DB, o *domain.Order) {
_, err := db.NamedExecContext(ctx, `
INSERT INTO orders (id, buyer_id, seller_id, status, total_cents, created_at, updated_at)
VALUES (:id, :buyer_id, :seller_id, :status, :total_cents, :created_at, :updated_at)
`, o)
if err != nil {
log.Printf("Error creating order: %v", err)
}
}
func createOrderItem(ctx context.Context, db *sqlx.DB, item *domain.OrderItem) {
_, err := db.NamedExecContext(ctx, `
INSERT INTO order_items (id, order_id, product_id, quantity, unit_cents, batch, expires_at)
VALUES (:id, :order_id, :product_id, :quantity, :unit_cents, :batch, :expires_at)
`, item)
if err != nil {
log.Printf("Error creating order item: %v", err)
}
}

View file

@ -3,6 +3,8 @@ package domain
import (
"math"
"time"
"github.com/gofrs/uuid/v5"
)
const earthRadiusKm = 6371.0
@ -43,17 +45,18 @@ type ProductWithDistance struct {
// ProductSearchFilter captures search constraints.
type ProductSearchFilter struct {
Search string
Category string
MinPriceCents *int64
MaxPriceCents *int64
ExpiresAfter *time.Time
ExpiresBefore *time.Time
MaxDistanceKm *float64
BuyerLat float64
BuyerLng float64
Limit int
Offset int
Search string
Category string
MinPriceCents *int64
MaxPriceCents *int64
ExpiresAfter *time.Time
ExpiresBefore *time.Time
MaxDistanceKm *float64
BuyerLat float64
BuyerLng float64
ExcludeSellerID *uuid.UUID // Exclude products from buyer's own company
Limit int
Offset int
}
// ProductSearchPage wraps search results with pagination.

View file

@ -249,6 +249,23 @@ type ShippingAddress struct {
Country string `json:"country" db:"shipping_country"`
}
// ShippingSettings stores configuration for calculating delivery fees.
type ShippingSettings struct {
VendorID uuid.UUID `db:"vendor_id" json:"vendor_id"`
Active bool `db:"active" json:"active"`
MaxRadiusKm float64 `db:"max_radius_km" json:"max_radius_km"`
PricePerKmCents int64 `db:"price_per_km_cents" json:"price_per_km_cents"`
MinFeeCents int64 `db:"min_fee_cents" json:"min_fee_cents"`
FreeShippingThresholdCents *int64 `db:"free_shipping_threshold_cents" json:"free_shipping_threshold_cents"`
PickupActive bool `db:"pickup_active" json:"pickup_active"`
PickupAddress string `db:"pickup_address" json:"pickup_address"`
PickupHours string `db:"pickup_hours" json:"pickup_hours"`
Latitude float64 `db:"latitude" json:"latitude"`
Longitude float64 `db:"longitude" json:"longitude"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// Shipment stores freight label data and tracking linkage.
type Shipment struct {
ID uuid.UUID `db:"id" json:"id"`

View file

@ -171,20 +171,17 @@ type updateStatusRequest struct {
Status string `json:"status"`
}
type shippingMethodRequest struct {
Type string `json:"type"`
type shippingSettingsRequest struct {
Active bool `json:"active"`
PreparationMinutes int `json:"preparation_minutes"`
MaxRadiusKm float64 `json:"max_radius_km"`
MinFeeCents int64 `json:"min_fee_cents"`
PricePerKmCents int64 `json:"price_per_km_cents"`
MinFeeCents int64 `json:"min_fee_cents"`
FreeShippingThresholdCents *int64 `json:"free_shipping_threshold_cents,omitempty"`
PickupActive bool `json:"pickup_active"`
PickupAddress string `json:"pickup_address,omitempty"`
PickupHours string `json:"pickup_hours,omitempty"`
}
type shippingSettingsRequest struct {
Methods []shippingMethodRequest `json:"methods"`
Latitude float64 `json:"latitude"` // Store location for radius calc
Longitude float64 `json:"longitude"`
}
type shippingCalculateRequest struct {

View file

@ -7,6 +7,7 @@ import (
"time"
"github.com/saveinmed/backend-go/internal/domain"
"github.com/saveinmed/backend-go/internal/http/middleware"
)
// CreateProduct godoc
@ -131,6 +132,11 @@ func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) {
}
}
// Exclude products from the buyer's own company
if claims, ok := middleware.GetClaims(r.Context()); ok && claims.CompanyID != nil {
filter.ExcludeSellerID = claims.CompanyID
}
result, err := h.svc.SearchProducts(r.Context(), filter, page, pageSize)
if err != nil {
writeError(w, http.StatusInternalServerError, err)

View file

@ -16,7 +16,7 @@ import (
// @Tags Shipping
// @Produce json
// @Param vendor_id path string true "Vendor ID"
// @Success 200 {array} domain.ShippingMethod
// @Success 200 {object} domain.ShippingSettings
// @Failure 400 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 500 {object} map[string]string
@ -40,12 +40,17 @@ func (h *Handler) GetShippingSettings(w http.ResponseWriter, r *http.Request) {
}
}
methods, err := h.svc.GetShippingMethods(r.Context(), vendorID)
settings, err := h.svc.GetShippingSettings(r.Context(), vendorID)
if err != nil {
// Log error if needed, but for 404/not found we might return empty object
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, methods)
if settings == nil {
// Return defaults
settings = &domain.ShippingSettings{VendorID: vendorID, Active: false}
}
writeJSON(w, http.StatusOK, settings)
}
// UpsertShippingSettings godoc
@ -56,7 +61,7 @@ func (h *Handler) GetShippingSettings(w http.ResponseWriter, r *http.Request) {
// @Produce json
// @Param vendor_id path string true "Vendor ID"
// @Param payload body shippingSettingsRequest true "Shipping settings"
// @Success 200 {array} domain.ShippingMethod
// @Success 200 {object} domain.ShippingSettings
// @Failure 400 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 500 {object} map[string]string
@ -85,61 +90,43 @@ func (h *Handler) UpsertShippingSettings(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusBadRequest, err)
return
}
if len(req.Methods) == 0 {
writeError(w, http.StatusBadRequest, errors.New("methods are required"))
return
}
methods := make([]domain.ShippingMethod, 0, len(req.Methods))
for _, method := range req.Methods {
methodType, err := parseShippingMethodType(method.Type)
if err != nil {
writeError(w, http.StatusBadRequest, err)
if req.Active {
if req.MaxRadiusKm < 0 {
writeError(w, http.StatusBadRequest, errors.New("max_radius_km must be >= 0"))
return
}
if method.PreparationMinutes < 0 {
writeError(w, http.StatusBadRequest, errors.New("preparation_minutes must be >= 0"))
if req.PricePerKmCents < 0 || req.MinFeeCents < 0 {
writeError(w, http.StatusBadRequest, errors.New("pricing fields must be >= 0"))
return
}
if methodType != domain.ShippingMethodPickup {
if method.Active && method.MaxRadiusKm <= 0 {
writeError(w, http.StatusBadRequest, errors.New("max_radius_km must be > 0 for active delivery methods"))
return
}
if method.MinFeeCents < 0 || method.PricePerKmCents < 0 {
writeError(w, http.StatusBadRequest, errors.New("delivery pricing must be >= 0"))
return
}
if method.Active && method.FreeShippingThresholdCents != nil && *method.FreeShippingThresholdCents <= 0 {
writeError(w, http.StatusBadRequest, errors.New("free_shipping_threshold_cents must be > 0"))
return
}
}
if req.PickupActive {
if strings.TrimSpace(req.PickupAddress) == "" || strings.TrimSpace(req.PickupHours) == "" {
writeError(w, http.StatusBadRequest, errors.New("pickup_address and pickup_hours are required for active pickup"))
return
}
if methodType == domain.ShippingMethodPickup && method.Active {
if strings.TrimSpace(method.PickupAddress) == "" || strings.TrimSpace(method.PickupHours) == "" {
writeError(w, http.StatusBadRequest, errors.New("pickup_address and pickup_hours are required for active pickup"))
return
}
}
methods = append(methods, domain.ShippingMethod{
Type: methodType,
Active: method.Active,
PreparationMinutes: method.PreparationMinutes,
MaxRadiusKm: method.MaxRadiusKm,
MinFeeCents: method.MinFeeCents,
PricePerKmCents: method.PricePerKmCents,
FreeShippingThresholdCents: method.FreeShippingThresholdCents,
PickupAddress: strings.TrimSpace(method.PickupAddress),
PickupHours: strings.TrimSpace(method.PickupHours),
})
}
updated, err := h.svc.UpsertShippingMethods(r.Context(), vendorID, methods)
if err != nil {
settings := &domain.ShippingSettings{
VendorID: vendorID,
Active: req.Active,
MaxRadiusKm: req.MaxRadiusKm,
PricePerKmCents: req.PricePerKmCents,
MinFeeCents: req.MinFeeCents,
FreeShippingThresholdCents: req.FreeShippingThresholdCents,
PickupActive: req.PickupActive,
PickupAddress: req.PickupAddress,
PickupHours: req.PickupHours,
Latitude: req.Latitude,
Longitude: req.Longitude,
}
if err := h.svc.UpsertShippingSettings(r.Context(), settings); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, updated)
writeJSON(w, http.StatusOK, settings)
}
// CalculateShipping godoc

View file

@ -25,51 +25,74 @@ type Claims struct {
func RequireAuth(secret []byte, allowedRoles ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
w.WriteHeader(http.StatusUnauthorized)
return
}
tokenStr := strings.TrimSpace(authHeader[7:])
claims := jwt.MapClaims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return secret, nil
})
if err != nil || !token.Valid {
w.WriteHeader(http.StatusUnauthorized)
return
}
role, _ := claims["role"].(string)
if len(allowedRoles) > 0 && !isRoleAllowed(role, allowedRoles) {
w.WriteHeader(http.StatusForbidden)
return
}
sub, _ := claims["sub"].(string)
userID, err := uuid.FromString(sub)
claims, err := parseToken(r, secret)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
var companyID *uuid.UUID
if cid, ok := claims["company_id"].(string); ok && cid != "" {
if parsed, err := uuid.FromString(cid); err == nil {
companyID = &parsed
}
if len(allowedRoles) > 0 && !isRoleAllowed(claims.Role, allowedRoles) {
w.WriteHeader(http.StatusForbidden)
return
}
ctx := context.WithValue(r.Context(), claimsKey, Claims{UserID: userID, Role: role, CompanyID: companyID})
ctx := context.WithValue(r.Context(), claimsKey, *claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// OptionalAuth attempts to validate a JWT token if present, but proceeds without context if missing or invalid.
func OptionalAuth(secret []byte) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, err := parseToken(r, secret)
if err == nil && claims != nil {
ctx := context.WithValue(r.Context(), claimsKey, *claims)
next.ServeHTTP(w, r.WithContext(ctx))
} else {
next.ServeHTTP(w, r)
}
})
}
}
func parseToken(r *http.Request, secret []byte) (*Claims, error) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
return nil, errors.New("missing bearer header")
}
tokenStr := strings.TrimSpace(authHeader[7:])
jwtClaims := jwt.MapClaims{}
token, err := jwt.ParseWithClaims(tokenStr, jwtClaims, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return secret, nil
})
if err != nil || !token.Valid {
return nil, err
}
sub, _ := jwtClaims["sub"].(string)
userID, err := uuid.FromString(sub)
if err != nil {
return nil, errors.New("invalid sub")
}
role, _ := jwtClaims["role"].(string)
var companyID *uuid.UUID
if cid, ok := jwtClaims["company_id"].(string); ok && cid != "" {
if parsed, err := uuid.FromString(cid); err == nil {
companyID = &parsed
}
}
return &Claims{UserID: userID, Role: role, CompanyID: companyID}, nil
}
// GetClaims extracts JWT claims from the request context.
func GetClaims(ctx context.Context) (Claims, bool) {
claims, ok := ctx.Value(claimsKey).(Claims)

View file

@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS shipping_settings (
vendor_id UUID PRIMARY KEY,
active BOOLEAN DEFAULT true,
-- Configuração de Entrega
max_radius_km DOUBLE PRECISION DEFAULT 0,
price_per_km_cents BIGINT DEFAULT 0,
min_fee_cents BIGINT DEFAULT 0,
free_shipping_threshold_cents BIGINT, -- Nova opção de frete grátis
-- Configuração de Retirada
pickup_active BOOLEAN DEFAULT false,
pickup_address TEXT, -- JSON ou texto formatado
pickup_hours TEXT,
-- Geolocalização da loja (para cálculo do raio)
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Index para busca rápida por vendedor
CREATE INDEX IF NOT EXISTS idx_shipping_settings_vendor_id ON shipping_settings(vendor_id);

View file

@ -308,6 +308,10 @@ func (r *Repository) SearchProducts(ctx context.Context, filter domain.ProductSe
clauses = append(clauses, fmt.Sprintf("p.expires_at <= $%d", len(args)+1))
args = append(args, *filter.ExpiresBefore)
}
if filter.ExcludeSellerID != nil {
clauses = append(clauses, fmt.Sprintf("p.seller_id != $%d", len(args)+1))
args = append(args, *filter.ExcludeSellerID)
}
// Always filter only available products
clauses = append(clauses, "p.stock > 0")
@ -471,6 +475,22 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`
_ = tx.Rollback()
return err
}
// Reduce stock
res, err := tx.ExecContext(ctx, `UPDATE products SET stock = stock - $1, updated_at = $2 WHERE id = $3 AND stock >= $1`, item.Quantity, now, item.ProductID)
if err != nil {
_ = tx.Rollback()
return err
}
rows, err := res.RowsAffected()
if err != nil {
_ = tx.Rollback()
return err
}
if rows == 0 {
_ = tx.Rollback()
return fmt.Errorf("insufficient stock for product %s", item.ProductID)
}
}
return tx.Commit()
@ -1141,3 +1161,49 @@ func (r *Repository) ListShipments(ctx context.Context, filter domain.ShipmentFi
}
return shipments, total, nil
}
func (r *Repository) GetShippingSettings(ctx context.Context, vendorID uuid.UUID) (*domain.ShippingSettings, error) {
var settings domain.ShippingSettings
err := r.db.GetContext(ctx, &settings, `SELECT * FROM shipping_settings WHERE vendor_id = $1`, vendorID)
if err != nil {
return nil, err
}
return &settings, nil
}
func (r *Repository) UpsertShippingSettings(ctx context.Context, settings *domain.ShippingSettings) error {
now := time.Now().UTC()
settings.UpdatedAt = now
// Create if new
if settings.CreatedAt.IsZero() {
settings.CreatedAt = now
}
query := `
INSERT INTO shipping_settings (
vendor_id, active, max_radius_km, price_per_km_cents, min_fee_cents,
free_shipping_threshold_cents, pickup_active, pickup_address, pickup_hours,
latitude, longitude, created_at, updated_at
) VALUES (
:vendor_id, :active, :max_radius_km, :price_per_km_cents, :min_fee_cents,
:free_shipping_threshold_cents, :pickup_active, :pickup_address, :pickup_hours,
:latitude, :longitude, :created_at, :updated_at
)
ON CONFLICT (vendor_id) DO UPDATE SET
active = :active,
max_radius_km = :max_radius_km,
price_per_km_cents = :price_per_km_cents,
min_fee_cents = :min_fee_cents,
free_shipping_threshold_cents = :free_shipping_threshold_cents,
pickup_active = :pickup_active,
pickup_address = :pickup_address,
pickup_hours = :pickup_hours,
latitude = :latitude,
longitude = :longitude,
updated_at = :updated_at
`
_, err := r.db.NamedExecContext(ctx, query, settings)
return err
}

View file

@ -72,7 +72,7 @@ func New(cfg config.Config) (*Server, error) {
mux.Handle("POST /api/v1/products", chain(http.HandlerFunc(h.CreateProduct), middleware.Logger, middleware.Gzip))
mux.Handle("GET /api/v1/products", chain(http.HandlerFunc(h.ListProducts), middleware.Logger, middleware.Gzip))
mux.Handle("GET /api/v1/products/search", chain(http.HandlerFunc(h.SearchProducts), middleware.Logger, middleware.Gzip))
mux.Handle("GET /api/v1/products/search", chain(http.HandlerFunc(h.SearchProducts), middleware.Logger, middleware.Gzip, middleware.OptionalAuth([]byte(cfg.JWTSecret))))
mux.Handle("GET /api/v1/products/{id}", chain(http.HandlerFunc(h.GetProduct), middleware.Logger, middleware.Gzip))
mux.Handle("GET /api/v1/marketplace/records", chain(http.HandlerFunc(h.ListMarketplaceRecords), middleware.Logger, middleware.Gzip))

View file

@ -58,9 +58,8 @@ type Repository interface {
GetCompanyRating(ctx context.Context, companyID uuid.UUID) (*domain.CompanyRating, error)
SellerDashboard(ctx context.Context, sellerID uuid.UUID) (*domain.SellerDashboard, error)
AdminDashboard(ctx context.Context, since time.Time) (*domain.AdminDashboard, error)
GetShippingMethodsByVendor(ctx context.Context, vendorID uuid.UUID) ([]domain.ShippingMethod, error)
UpsertShippingMethods(ctx context.Context, methods []domain.ShippingMethod) error
GetShippingSettings(ctx context.Context, vendorID uuid.UUID) (*domain.ShippingSettings, error)
UpsertShippingSettings(ctx context.Context, settings *domain.ShippingSettings) error
ListReviews(ctx context.Context, filter domain.ReviewFilter) ([]domain.Review, int64, error)
ListShipments(ctx context.Context, filter domain.ShipmentFilter) ([]domain.Shipment, int64, error)
}
@ -120,29 +119,6 @@ func (s *Service) GetCompany(ctx context.Context, id uuid.UUID) (*domain.Company
return s.repo.GetCompany(ctx, id)
}
func (s *Service) GetShippingMethods(ctx context.Context, vendorID uuid.UUID) ([]domain.ShippingMethod, error) {
return s.repo.GetShippingMethodsByVendor(ctx, vendorID)
}
func (s *Service) UpsertShippingMethods(ctx context.Context, vendorID uuid.UUID, methods []domain.ShippingMethod) ([]domain.ShippingMethod, error) {
if len(methods) == 0 {
return nil, errors.New("shipping methods are required")
}
for i := range methods {
if methods[i].Type == "" {
return nil, errors.New("shipping method type is required")
}
if methods[i].ID == uuid.Nil {
methods[i].ID = uuid.Must(uuid.NewV7())
}
methods[i].VendorID = vendorID
}
if err := s.repo.UpsertShippingMethods(ctx, methods); err != nil {
return nil, err
}
return s.repo.GetShippingMethodsByVendor(ctx, vendorID)
}
func (s *Service) CalculateShippingOptions(ctx context.Context, vendorID uuid.UUID, buyerLat, buyerLng float64, cartTotalCents int64) ([]domain.ShippingOption, error) {
company, err := s.repo.GetCompany(ctx, vendorID)
if err != nil {
@ -152,61 +128,62 @@ func (s *Service) CalculateShippingOptions(ctx context.Context, vendorID uuid.UU
return nil, errors.New("vendor not found")
}
methods, err := s.repo.GetShippingMethodsByVendor(ctx, vendorID)
settings, err := s.repo.GetShippingSettings(ctx, vendorID)
if err != nil {
return nil, err
// Just return empty options if settings not found
return []domain.ShippingOption{}, nil
}
if settings == nil {
return []domain.ShippingOption{}, nil
}
distance := domain.HaversineDistance(company.Latitude, company.Longitude, buyerLat, buyerLng)
var options []domain.ShippingOption
for _, method := range methods {
if !method.Active {
continue
}
switch method.Type {
case domain.ShippingMethodPickup:
description := "Pickup at seller location"
if method.PickupAddress != "" {
description = "Pickup at " + method.PickupAddress
}
if method.PickupHours != "" {
description += " (" + method.PickupHours + ")"
}
options = append(options, domain.ShippingOption{
Type: domain.ShippingOptionTypePickup,
ValueCents: 0,
EstimatedMinutes: method.PreparationMinutes,
Description: description,
DistanceKm: distance,
})
case domain.ShippingMethodOwnDelivery, domain.ShippingMethodThirdParty:
if method.MaxRadiusKm > 0 && distance > method.MaxRadiusKm {
continue
}
variableCost := int64(math.Round(distance * float64(method.PricePerKmCents)))
price := method.MinFeeCents
// 1. Delivery
if settings.Active {
if settings.MaxRadiusKm > 0 && distance <= settings.MaxRadiusKm {
variableCost := int64(math.Round(distance * float64(settings.PricePerKmCents)))
price := settings.MinFeeCents
if variableCost > price {
price = variableCost
}
if method.FreeShippingThresholdCents != nil && cartTotalCents >= *method.FreeShippingThresholdCents {
if settings.FreeShippingThresholdCents != nil && *settings.FreeShippingThresholdCents > 0 && cartTotalCents >= *settings.FreeShippingThresholdCents {
price = 0
}
estimatedMinutes := method.PreparationMinutes + int(math.Round(distance*5))
description := "Delivery via own fleet"
if method.Type == domain.ShippingMethodThirdParty {
description = "Delivery via third-party courier"
}
// Estimate: 30 mins base + 5 mins/km default
estMins := 30 + int(math.Round(distance*5))
options = append(options, domain.ShippingOption{
Type: domain.ShippingOptionTypeDelivery,
ValueCents: price,
EstimatedMinutes: estimatedMinutes,
Description: description,
EstimatedMinutes: estMins,
Description: "Entrega Própria",
DistanceKm: distance,
})
}
}
// 2. Pickup
if settings.PickupActive {
desc := "Retirada na loja"
if settings.PickupAddress != "" {
desc = "Retirada em: " + settings.PickupAddress
}
if settings.PickupHours != "" {
desc += " (" + settings.PickupHours + ")"
}
options = append(options, domain.ShippingOption{
Type: domain.ShippingOptionTypePickup,
ValueCents: 0,
EstimatedMinutes: 60, // Default 1 hour readily available
Description: desc,
DistanceKm: distance,
})
}
return options, nil
}
@ -843,3 +820,11 @@ func (s *Service) ListShipments(ctx context.Context, filter domain.ShipmentFilte
}
return &domain.ShipmentPage{Shipments: shipments, Total: total, Page: page, PageSize: pageSize}, nil
}
func (s *Service) GetShippingSettings(ctx context.Context, vendorID uuid.UUID) (*domain.ShippingSettings, error) {
return s.repo.GetShippingSettings(ctx, vendorID)
}
func (s *Service) UpsertShippingSettings(ctx context.Context, settings *domain.ShippingSettings) error {
return s.repo.UpsertShippingSettings(ctx, settings)
}

View file

@ -9,6 +9,7 @@ import { SellerDashboardPage } from './pages/SellerDashboard'
import { EmployeeDashboardPage } from './pages/EmployeeDashboard'
import { DeliveryDashboardPage } from './pages/DeliveryDashboard'
import { MyProfilePage } from './pages/MyProfile'
import { CheckoutPage } from './pages/Checkout'
import ProductSearch from './pages/ProductSearch'
import { ProtectedRoute } from './components/ProtectedRoute'
import { DashboardLayout } from './layouts/DashboardLayout'
@ -20,7 +21,8 @@ import {
OrdersPage,
ReviewsPage,
LogisticsPage,
ProfilePage
ProfilePage,
ShippingSettingsPage
} from './pages/admin'
function App() {
@ -144,6 +146,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/checkout"
element={
<ProtectedRoute>
<CheckoutPage />
</ProtectedRoute>
}
/>
{/* Product Search - Buy from other pharmacies */}
<Route
path="/search"
@ -153,6 +163,15 @@ function App() {
</ProtectedRoute>
}
/>
{/* Shipping Settings - for Sellers */}
<Route
path="/shipping-settings"
element={
<ProtectedRoute allowedRoles={['owner', 'seller']}>
<ShippingSettingsPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
)

View file

@ -1,4 +1,5 @@
import { ShoppingBasket } from 'lucide-react'
import { Link } from 'react-router-dom'
import { Shell } from '../layouts/Shell'
import { selectGroupedCart, selectCartSummary, useCartStore } from '../stores/cartStore'
import { formatCurrency } from '../utils/format'
@ -119,12 +120,12 @@ export function CartPage() {
<button className="rounded bg-gray-200 px-4 py-2 text-sm font-semibold" onClick={clearAll}>
Limpar carrinho
</button>
<a
href="/checkout"
<Link
to="/checkout"
className="rounded bg-healthGreen px-4 py-2 text-sm font-semibold text-white"
>
Seguir para checkout unificado
</a>
</Link>
</div>
)}
</div>

View file

@ -1,78 +1,241 @@
import { useEffect, useMemo, useState } from 'react'
import { initMercadoPago, Payment } from '@mercadopago/sdk-react'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Shell } from '../layouts/Shell'
import { selectGroupedCart, selectCartSummary, useCartStore } from '../stores/cartStore'
import { useCartStore, selectGroupedCart, selectCartSummary } from '../stores/cartStore'
import { useAuth } from '../context/AuthContext'
const MP_PUBLIC_KEY = import.meta.env.VITE_MP_PUBLIC_KEY || 'TEST-PUBLIC-KEY'
// Helper function to safely format currency values
const formatCurrency = (value: number | undefined | null): string => {
if (value === undefined || value === null || isNaN(value)) {
return '0,00'
}
return value.toFixed(2).replace('.', ',')
}
import { ordersService, CreateOrderRequest } from '../services/ordersService'
import { formatCurrency } from '../utils/format'
import { ArrowLeft, CheckCircle2, Truck } from 'lucide-react'
export function CheckoutPage() {
const navigate = useNavigate()
const { user } = useAuth()
const summary = useCartStore(selectCartSummary)
const groups = useCartStore(selectGroupedCart)
const [status, setStatus] = useState<'pendente' | 'pago' | null>(null)
const summary = useCartStore(selectCartSummary)
const clearAll = useCartStore((state) => state.clearAll)
useEffect(() => {
initMercadoPago(MP_PUBLIC_KEY, { locale: 'pt-BR' })
}, [])
const [loading, setLoading] = useState(false)
const [shipping, setShipping] = useState({
recipient_name: user?.name || '',
street: '',
number: '',
complement: '',
district: '',
city: '',
state: '',
zip_code: '',
country: 'Brasil'
})
const preferenceId = useMemo(() => `pref-${Date.now()}`, [])
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setShipping(prev => ({ ...prev, [name]: value }))
}
const handlePlaceOrder = async () => {
if (!user) return
setLoading(true)
try {
// Create an order for each vendor group
const promises = Object.entries(groups).map(([sellerId, group]) => {
const orderData: CreateOrderRequest = {
buyer_id: user.id,
seller_id: sellerId,
items: group.items.map(item => ({
product_id: item.id,
quantity: item.quantity,
unit_cents: item.unitPrice,
batch: item.batch,
expires_at: item.expiry // Ensure format matches backend expectation? Backend expects ISO. Cart stores string?
})),
shipping: {
recipient_name: shipping.recipient_name,
street: shipping.street,
number: shipping.number,
complement: shipping.complement,
district: shipping.district,
city: shipping.city,
state: shipping.state,
zip_code: shipping.zip_code,
country: shipping.country
}
}
return ordersService.createOrder(orderData)
})
await Promise.all(promises)
clearAll()
navigate('/orders')
} catch (error) {
console.error('Failed to create order', error)
alert('Erro ao criar pedido. Verifique os dados e tente novamente.')
} finally {
setLoading(false)
}
}
if (summary.totalItems === 0) {
return (
<Shell>
<div className="flex flex-col items-center justify-center py-20">
<h2 className="text-xl font-semibold">Seu carrinho está vazio</h2>
<button onClick={() => navigate('/inventory')} className="mt-4 text-medicalBlue hover:underline">
Voltar para o catálogo
</button>
</div>
</Shell>
)
}
return (
<Shell>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-3 rounded-lg bg-white p-4 shadow-sm">
<div className="flex items-center justify-between border-b border-gray-200 pb-3">
<div>
<h1 className="text-xl font-semibold text-medicalBlue">Checkout e Pagamento</h1>
<p className="text-sm text-gray-600">Split automático por distribuidora, status pendente/pago.</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-500">Comprador</p>
<p className="font-semibold text-gray-800">{user?.name}</p>
</div>
</div>
{Object.entries(groups).map(([vendorId, group]) => (
<div key={vendorId} className="rounded border border-gray-200 bg-gray-50 p-3">
<div className="flex items-center justify-between">
<p className="font-semibold text-gray-800">{group.vendorName}</p>
<p className="text-sm font-bold text-medicalBlue">R$ {formatCurrency(group.total)}</p>
<div className="mx-auto max-w-4xl space-y-6">
<button onClick={() => navigate('/cart')} className="flex items-center text-sm text-gray-500 hover:text-gray-900">
<ArrowLeft className="mr-1 h-4 w-4" /> Voltar para o carrinho
</button>
<h1 className="text-2xl font-bold text-gray-900">Finalizar Compra</h1>
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{/* Left Column: Form */}
<div className="space-y-6 md:col-span-2">
<div className="rounded-lg bg-white p-6 shadow-sm">
<div className="mb-4 flex items-center gap-2">
<Truck className="h-5 w-5 text-medicalBlue" />
<h2 className="text-lg font-semibold text-gray-800">Endereço de Entrega</h2>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="mb-1 block text-sm font-medium text-gray-700">Destinatário</label>
<input
type="text"
name="recipient_name"
value={shipping.recipient_name}
onChange={handleInputChange}
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-medicalBlue focus:outline-none focus:ring-1 focus:ring-medicalBlue"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">CEP</label>
<input
type="text"
name="zip_code"
value={shipping.zip_code}
onChange={handleInputChange}
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-medicalBlue focus:outline-none focus:ring-1 focus:ring-medicalBlue"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Rua</label>
<input
type="text"
name="street"
value={shipping.street}
onChange={handleInputChange}
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-medicalBlue focus:outline-none focus:ring-1 focus:ring-medicalBlue"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Número</label>
<input
type="text"
name="number"
value={shipping.number}
onChange={handleInputChange}
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-medicalBlue focus:outline-none focus:ring-1 focus:ring-medicalBlue"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Complemento</label>
<input
type="text"
name="complement"
value={shipping.complement}
onChange={handleInputChange}
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-medicalBlue focus:outline-none focus:ring-1 focus:ring-medicalBlue"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Bairro</label>
<input
type="text"
name="district"
value={shipping.district}
onChange={handleInputChange}
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-medicalBlue focus:outline-none focus:ring-1 focus:ring-medicalBlue"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Cidade</label>
<input
type="text"
name="city"
value={shipping.city}
onChange={handleInputChange}
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-medicalBlue focus:outline-none focus:ring-1 focus:ring-medicalBlue"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Estado</label>
<input
type="text"
name="state"
value={shipping.state}
onChange={handleInputChange}
maxLength={2}
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-medicalBlue focus:outline-none focus:ring-1 focus:ring-medicalBlue"
/>
</div>
</div>
</div>
{/* Payment Method Stub */}
<div className="rounded-lg bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-gray-800">Pagamento</h2>
<p className="mt-2 text-sm text-gray-600">Este é um ambiente de demonstração. O pagamento será processado como "Confirmado" para fins de teste.</p>
<div className="mt-4 flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 p-4">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<span className="text-sm font-medium text-green-800">Método de Teste (Aprovação Automática)</span>
</div>
<p className="text-xs text-gray-600">Itens enviados no split de pagamento.</p>
</div>
))}
</div>
<div className="space-y-3 rounded-lg bg-white p-4 shadow-sm">
<div>
<p className="text-sm font-semibold text-gray-700">Resumo</p>
<p className="text-2xl font-bold text-medicalBlue">R$ {formatCurrency(summary.totalValue)}</p>
</div>
<div className="rounded bg-gray-100 p-3 text-sm">
<p className="font-semibold text-gray-700">Status</p>
<p className="text-gray-600">{status ? status.toUpperCase() : 'Aguardando pagamento'}</p>
</div>
<div className="rounded bg-gray-50 p-3">
<p className="text-sm font-semibold text-gray-700">Pagamento Mercado Pago</p>
<Payment
initialization={{ preferenceId, amount: summary.totalValue || 0.01 }}
customization={{ paymentMethods: { creditCard: 'all', bankTransfer: 'all' } }}
onSubmit={async () => {
setStatus('pendente')
}}
onReady={() => console.log('Payment brick pronto')}
onError={(error) => {
console.error(error)
setStatus(null)
}}
/>
{/* Right Column: Summary */}
<div className="space-y-6">
<div className="rounded-lg bg-white p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-gray-800">Resumo do Pedido</h2>
<div className="space-y-4">
{Object.entries(groups).map(([vendorId, group]) => (
<div key={vendorId} className="border-b border-gray-100 pb-4 last:border-0 last:pb-0">
<p className="mb-2 text-sm font-medium text-gray-600">{group.vendorName}</p>
{group.items.map(item => (
<div key={item.id} className="flex justify-between text-sm">
<span className="text-gray-800">{item.quantity}x {item.name}</span>
<span className="text-gray-600">R$ {formatCurrency(item.quantity * item.unitPrice)}</span>
</div>
))}
</div>
))}
<div className="mt-4 border-t border-gray-200 pt-4">
<div className="flex justify-between font-semibold text-gray-900">
<span>Total</span>
<span className="text-xl text-medicalBlue">R$ {formatCurrency(summary.totalValue)}</span>
</div>
</div>
<button
onClick={handlePlaceOrder}
disabled={loading}
className="mt-6 w-full rounded-lg bg-medicalBlue px-4 py-3 font-semibold text-white shadow-sm hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Processando...' : 'Confirmar Pedido'}
</button>
</div>
</div>
</div>
</div>
</div>

View file

@ -98,8 +98,8 @@ export function OrdersPage() {
<button
onClick={() => setActiveTab('compras')}
className={`flex-1 py-4 px-6 text-center font-medium transition-all relative ${activeTab === 'compras'
? 'text-blue-600 bg-blue-50'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
? 'text-blue-600 bg-blue-50'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}
>
<div className="flex items-center justify-center gap-3">
@ -116,8 +116,8 @@ export function OrdersPage() {
<button
onClick={() => setActiveTab('vendas')}
className={`flex-1 py-4 px-6 text-center font-medium transition-all relative ${activeTab === 'vendas'
? 'text-green-600 bg-green-50'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
? 'text-green-600 bg-green-50'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}
>
<div className="flex items-center justify-center gap-3">
@ -224,36 +224,69 @@ export function OrdersPage() {
{/* Actions - only for sales */}
{activeTab === 'vendas' && (
<div className="mt-4 pt-4 border-t border-gray-100 flex gap-2">
{order.status === 'Pendente' && (
<button
onClick={() => updateStatus(order.id, 'Pago')}
className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
>
💳 Confirmar Pagamento
</button>
)}
{order.status === 'Pago' && (
<button
onClick={() => updateStatus(order.id, 'Faturado')}
className="flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700 transition-colors"
>
📄 Faturar Pedido
</button>
)}
{order.status === 'Faturado' && (
<button
onClick={() => updateStatus(order.id, 'Entregue')}
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 transition-colors"
>
Confirmar Entrega
</button>
)}
{order.status === 'Entregue' && (
<span className="text-green-600 text-sm font-medium flex items-center gap-2">
Pedido concluído com sucesso
</span>
)}
<div className="mt-4 pt-4 border-t border-gray-100">
{/* Timeline for Sales */}
<div className="mb-4">
<div className="flex items-center justify-between">
{['Pendente', 'Pago', 'Faturado', 'Entregue'].map((status, idx) => {
const isCompleted = ['Pendente', 'Pago', 'Faturado', 'Entregue'].indexOf(order.status) >= idx
return (
<div key={status} className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${isCompleted
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-500'
}`}>
{isCompleted ? '✓' : idx + 1}
</div>
{idx < 3 && (
<div className={`w-16 h-1 mx-1 ${['Pendente', 'Pago', 'Faturado', 'Entregue'].indexOf(order.status) > idx
? 'bg-blue-600'
: 'bg-gray-200'
}`} />
)}
</div>
)
})}
</div>
<div className="flex justify-between mt-2 text-xs text-gray-500">
<span>Pendente</span>
<span>Pago</span>
<span>Faturado</span>
<span>Entregue</span>
</div>
</div>
<div className="flex gap-2 justify-end">
{order.status === 'Pendente' && (
<button
onClick={() => updateStatus(order.id, 'Pago')}
className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
>
💳 Confirmar Pagamento
</button>
)}
{order.status === 'Pago' && (
<button
onClick={() => updateStatus(order.id, 'Faturado')}
className="flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700 transition-colors"
>
📄 Faturar Pedido
</button>
)}
{order.status === 'Faturado' && (
<button
onClick={() => updateStatus(order.id, 'Entregue')}
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 transition-colors"
>
Confirmar Entrega
</button>
)}
{order.status === 'Entregue' && (
<span className="text-green-600 text-sm font-medium flex items-center gap-2">
Pedido concluído com sucesso
</span>
)}
</div>
</div>
)}
@ -266,15 +299,15 @@ export function OrdersPage() {
return (
<div key={status} className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${isCompleted
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-500'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-500'
}`}>
{isCompleted ? '✓' : idx + 1}
</div>
{idx < 3 && (
<div className={`w-16 h-1 mx-1 ${['Pendente', 'Pago', 'Faturado', 'Entregue'].indexOf(order.status) > idx
? 'bg-blue-600'
: 'bg-gray-200'
? 'bg-blue-600'
: 'bg-gray-200'
}`} />
)}
</div>
@ -287,6 +320,18 @@ export function OrdersPage() {
<span>Faturado</span>
<span>Entregue</span>
</div>
{/* Confirm Receipt Action */}
{order.status === 'Faturado' && (
<div className="mt-4 flex justify-end">
<button
onClick={() => updateStatus(order.id, 'Entregue')}
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 transition-colors"
>
Confirmar Recebimento
</button>
</div>
)}
</div>
)}
</div>

View file

@ -0,0 +1,268 @@
import { useEffect, useState } from 'react'
import { MapContainer, TileLayer, Circle, useMapEvents, Marker, Popup } from 'react-leaflet'
import 'leaflet/dist/leaflet.css'
import { adminService, ShippingSettings } from '../../services/adminService'
import { useAuth } from '../../context/AuthContext'
import L from 'leaflet'
// Fix Leaflet marker icon issue in React
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;
function RadiusController({ center, setRadius }: { center: [number, number], setRadius: (r: number) => void }) {
const map = useMapEvents({
click(e) {
const distanceMeters = e.latlng.distanceTo(center);
setRadius(parseFloat((distanceMeters / 1000).toFixed(2)));
},
})
return null
}
export function ShippingSettingsPage() {
const { user } = useAuth()
const [settings, setSettings] = useState<ShippingSettings>({
active: false,
max_radius_km: 5,
price_per_km_cents: 0,
min_fee_cents: 0,
free_shipping_threshold_cents: 0,
pickup_active: false,
pickup_address: '',
pickup_hours: '',
latitude: -23.55052, // Default SP
longitude: -46.633308
})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (user?.companyId) {
loadSettings()
}
}, [user?.companyId])
const loadSettings = async () => {
if (!user?.companyId) return
setLoading(true)
try {
// Fetch company first to get canonical location if needed
const company = await adminService.getCompany(user.companyId)
let initialLat = company.latitude || -23.55052
let initialLng = company.longitude || -46.633308
const data = await adminService.getShippingSettings(user.companyId)
// If settings exist and have valid lat/long, use them. Otherwise use company's.
if (data && (data.latitude !== 0 || data.longitude !== 0)) {
initialLat = data.latitude
initialLng = data.longitude
}
setSettings({
...data,
// Ensure defaults if null/undefined
max_radius_km: data?.max_radius_km ?? 5,
price_per_km_cents: data?.price_per_km_cents ?? 0,
min_fee_cents: data?.min_fee_cents ?? 0,
free_shipping_threshold_cents: data?.free_shipping_threshold_cents ?? 0,
latitude: initialLat,
longitude: initialLng
})
} catch (err) {
console.error("Error loading settings", err)
} finally {
setLoading(false)
}
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!user?.companyId) return
setSaving(true)
try {
await adminService.upsertShippingSettings(user.companyId, settings)
alert('Configurações salvas com sucesso!')
} catch (err) {
console.error("Error saving settings", err)
alert('Erro ao salvar configurações.')
} finally {
setSaving(false)
}
}
const center: [number, number] = [settings.latitude, settings.longitude]
if (loading) return <div className="p-8 text-center">Carregando configurações de frete...</div>
return (
<div className="p-6 max-w-6xl mx-auto space-y-6">
<h1 className="text-2xl font-bold flex items-center gap-2">
<span className="text-2xl">🚚</span>
Configurações de Entrega e Retirada
</h1>
<form onSubmit={handleSave} className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Delivery Settings */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="text-xl">📍</span>
Entrega Própria
</h2>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" checked={settings.active} onChange={e => setSettings({ ...settings, active: e.target.checked })} />
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
<span className="ml-3 text-sm font-medium text-gray-900">{settings.active ? 'Ativado' : 'Desativado'}</span>
</label>
</div>
<div className={`space-y-4 ${!settings.active && 'opacity-50 pointer-events-none'}`}>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Raio de Entrega (km)</label>
<input
type="number"
step="0.1"
className="w-full p-2 border rounded"
value={settings.max_radius_km}
onChange={e => setSettings({ ...settings, max_radius_km: parseFloat(e.target.value) })}
/>
<p className="text-xs text-gray-500 mt-1">Clique no mapa para definir o raio visualmente.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Preço por Km (Centavos)</label>
<div className="relative">
<span className="absolute left-3 top-2 text-gray-500">R$</span>
<input
type="number"
className="w-full pl-8 p-2 border rounded"
value={(settings.price_per_km_cents / 100).toFixed(2)}
onChange={e => setSettings({ ...settings, price_per_km_cents: Math.round(parseFloat(e.target.value) * 100) })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Taxa Mínima</label>
<div className="relative">
<span className="absolute left-3 top-2 text-gray-500">R$</span>
<input
type="number"
className="w-full pl-8 p-2 border rounded"
value={(settings.min_fee_cents / 100).toFixed(2)}
onChange={e => setSettings({ ...settings, min_fee_cents: Math.round(parseFloat(e.target.value) * 100) })}
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Frete Grátis acima de</label>
<div className="relative">
<span className="absolute left-3 top-2 text-gray-500">R$</span>
<input
type="number"
className="w-full pl-8 p-2 border rounded"
value={((settings.free_shipping_threshold_cents || 0) / 100).toFixed(2)}
onChange={e => setSettings({ ...settings, free_shipping_threshold_cents: Math.round(parseFloat(e.target.value) * 100) })}
/>
</div>
</div>
</div>
{/* MAP AREA */}
<div className="mt-6 h-64 w-full rounded-lg overflow-hidden border border-gray-300 relative z-0">
{settings.latitude !== 0 && (
<MapContainer center={center} zoom={13} style={{ height: '100%', width: '100%' }}>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
/>
<Marker position={center}>
<Popup>Sua Loja</Popup>
</Marker>
{settings.active && (
<Circle
center={center}
radius={settings.max_radius_km * 1000}
pathOptions={{ fillColor: 'blue', color: 'blue', opacity: 0.5 }}
/>
)}
{settings.active && (
<RadiusController
center={center}
setRadius={(r) => setSettings(s => ({ ...s, max_radius_km: r }))}
/>
)}
</MapContainer>
)}
</div>
</div>
{/* Pickup Settings */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 h-fit">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="text-xl">🏪</span>
Retirada na Loja
</h2>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" checked={settings.pickup_active} onChange={e => setSettings({ ...settings, pickup_active: e.target.checked })} />
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-emerald-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-emerald-600"></div>
<span className="ml-3 text-sm font-medium text-gray-900">{settings.pickup_active ? 'Ativado' : 'Desativado'}</span>
</label>
</div>
<div className={`space-y-4 ${!settings.pickup_active && 'opacity-50 pointer-events-none'}`}>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Endereço de Retirada</label>
<textarea
className="w-full p-2 border rounded h-24"
placeholder="Rua Exemplo, 123 - Bairro..."
value={settings.pickup_address}
onChange={e => setSettings({ ...settings, pickup_address: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Horário de Retirada</label>
<input
type="text"
className="w-full p-2 border rounded"
placeholder="Seg-Sex 08:00 às 18:00..."
value={settings.pickup_hours}
onChange={e => setSettings({ ...settings, pickup_hours: e.target.value })}
/>
</div>
<div className="bg-blue-50 p-4 rounded-lg flex gap-3 text-blue-700 text-sm">
<span className="text-xl"></span>
<p>Ao ativar a retirada, os clientes poderão escolher buscar o pedido na loja sem custo de frete.</p>
</div>
</div>
</div>
</form>
<div className="flex justify-end pt-4 border-t">
<button
onClick={handleSave}
disabled={saving}
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2 rounded-lg font-medium flex items-center gap-2 transition-colors disabled:opacity-50"
>
{saving ? 'Salvando...' : 'Salvar Configurações'}
<span className="text-sm">💾</span>
</button>
</div>
</div>
)
}

View file

@ -40,7 +40,7 @@ export function UsersPage() {
const loadCompanies = async () => {
try {
const data = await adminService.listCompanies(1, 100)
const data = await adminService.listCompanies(1, 1000)
setCompanies(data.tenants || [])
} catch (err) {
console.error('Error loading companies:', err)
@ -58,9 +58,10 @@ export function UsersPage() {
setShowModal(false)
resetForm()
loadUsers()
} catch (err) {
loadUsers()
} catch (err: any) {
console.error('Error saving user:', err)
alert('Erro ao salvar usuário')
alert(`Erro ao salvar usuário: ${err.response?.data?.error || err.message}`)
}
}

View file

@ -6,3 +6,5 @@ export { DashboardHome } from './DashboardHome'
export { ReviewsPage } from './ReviewsPage'
export { LogisticsPage } from './LogisticsPage'
export { ProfilePage } from './ProfilePage'
export { ShippingSettingsPage } from './ShippingSettings'

View file

@ -318,6 +318,21 @@ export const adminService = {
log('listShipments result', result)
return result
},
// ================== SHIPPING SETTINGS ==================
getShippingSettings: async (vendorID: string) => {
log('getShippingSettings', { vendorID })
const result = await apiClient.get<ShippingSettings>(`/v1/shipping/settings/${vendorID}`)
log('getShippingSettings result', result)
return result
},
upsertShippingSettings: async (vendorID: string, data: ShippingSettings) => {
log('upsertShippingSettings', { vendorID, data })
const result = await apiClient.put<ShippingSettings>(`/v1/shipping/settings/${vendorID}`, data)
log('upsertShippingSettings result', result)
return result
},
}
// ================== REVIEWS & SHIPMENTS TYPES ==================
@ -355,3 +370,18 @@ export interface ShipmentPage {
page: number
page_size: number
}
// ================== SHIPPING SETTINGS ==================
export interface ShippingSettings {
vendor_id?: string
active: boolean
max_radius_km: number
price_per_km_cents: number
min_fee_cents: number
free_shipping_threshold_cents?: number
pickup_active: boolean
pickup_address?: string
pickup_hours?: string
latitude: number
longitude: number
}

View file

@ -0,0 +1,38 @@
import { apiClient } from './apiClient'
export interface OrderItem {
product_id: string
quantity: number
unit_cents: number
batch: string
expires_at: string
}
export interface ShippingAddress {
recipient_name: string
street: string
number: string
complement?: string
district: string
city: string
state: string
zip_code: string
country: string
}
export interface CreateOrderRequest {
buyer_id: string
seller_id: string
items: OrderItem[]
shipping: ShippingAddress
}
export const ordersService = {
createOrder: (data: CreateOrderRequest) => {
return apiClient.post('/v1/orders', data)
},
listOrders: () => {
return apiClient.get('/v1/orders')
}
}