From baa60c0d9baff9a5801ed9d594e92978a5319120 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Tue, 23 Dec 2025 18:23:32 -0300 Subject: [PATCH] feat: overhaul shipping module, add seeder, and improve order UI --- backend/cmd/seeder/main.go | 276 +++++++++++++++++ backend/internal/domain/distance.go | 25 +- backend/internal/domain/models.go | 17 ++ backend/internal/http/handler/dto.go | 13 +- .../internal/http/handler/product_handler.go | 6 + .../internal/http/handler/shipping_handler.go | 83 +++-- backend/internal/http/middleware/auth.go | 89 ++++-- .../migrations/0007_shipping_settings.sql | 25 ++ .../internal/repository/postgres/postgres.go | 66 ++++ backend/internal/server/server.go | 2 +- backend/internal/usecase/usecase.go | 111 +++---- marketplace/src/App.tsx | 21 +- marketplace/src/pages/Cart.tsx | 7 +- marketplace/src/pages/Checkout.tsx | 285 ++++++++++++++---- marketplace/src/pages/Orders.tsx | 121 +++++--- .../src/pages/admin/ShippingSettings.tsx | 268 ++++++++++++++++ marketplace/src/pages/admin/UsersPage.tsx | 7 +- marketplace/src/pages/admin/index.ts | 2 + marketplace/src/services/adminService.ts | 30 ++ marketplace/src/services/ordersService.ts | 38 +++ 20 files changed, 1222 insertions(+), 270 deletions(-) create mode 100644 backend/cmd/seeder/main.go create mode 100644 backend/internal/repository/postgres/migrations/0007_shipping_settings.sql create mode 100644 marketplace/src/pages/admin/ShippingSettings.tsx create mode 100644 marketplace/src/services/ordersService.ts diff --git a/backend/cmd/seeder/main.go b/backend/cmd/seeder/main.go new file mode 100644 index 0000000..ec6f0e2 --- /dev/null +++ b/backend/cmd/seeder/main.go @@ -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) + } +} diff --git a/backend/internal/domain/distance.go b/backend/internal/domain/distance.go index b591c5b..c3a7ddf 100644 --- a/backend/internal/domain/distance.go +++ b/backend/internal/domain/distance.go @@ -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. diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index 05bd327..7104a2c 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -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"` diff --git a/backend/internal/http/handler/dto.go b/backend/internal/http/handler/dto.go index 854b3fd..8f375e3 100644 --- a/backend/internal/http/handler/dto.go +++ b/backend/internal/http/handler/dto.go @@ -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 { diff --git a/backend/internal/http/handler/product_handler.go b/backend/internal/http/handler/product_handler.go index 9fe0082..def0ab9 100644 --- a/backend/internal/http/handler/product_handler.go +++ b/backend/internal/http/handler/product_handler.go @@ -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) diff --git a/backend/internal/http/handler/shipping_handler.go b/backend/internal/http/handler/shipping_handler.go index a76d9d5..134832d 100644 --- a/backend/internal/http/handler/shipping_handler.go +++ b/backend/internal/http/handler/shipping_handler.go @@ -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 diff --git a/backend/internal/http/middleware/auth.go b/backend/internal/http/middleware/auth.go index 85b9519..0436171 100644 --- a/backend/internal/http/middleware/auth.go +++ b/backend/internal/http/middleware/auth.go @@ -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) diff --git a/backend/internal/repository/postgres/migrations/0007_shipping_settings.sql b/backend/internal/repository/postgres/migrations/0007_shipping_settings.sql new file mode 100644 index 0000000..4f8eec9 --- /dev/null +++ b/backend/internal/repository/postgres/migrations/0007_shipping_settings.sql @@ -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); diff --git a/backend/internal/repository/postgres/postgres.go b/backend/internal/repository/postgres/postgres.go index d8d4b5b..0cbe46b 100644 --- a/backend/internal/repository/postgres/postgres.go +++ b/backend/internal/repository/postgres/postgres.go @@ -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 +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 9f06dc8..150ade1 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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)) diff --git a/backend/internal/usecase/usecase.go b/backend/internal/usecase/usecase.go index 5b20b1b..655e231 100644 --- a/backend/internal/usecase/usecase.go +++ b/backend/internal/usecase/usecase.go @@ -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) +} diff --git a/marketplace/src/App.tsx b/marketplace/src/App.tsx index 8c76e89..5f18488 100644 --- a/marketplace/src/App.tsx +++ b/marketplace/src/App.tsx @@ -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() { } /> + + + + } + /> {/* Product Search - Buy from other pharmacies */} } /> + {/* Shipping Settings - for Sellers */} + + + + } + /> } /> ) diff --git a/marketplace/src/pages/Cart.tsx b/marketplace/src/pages/Cart.tsx index a6010cc..a2b9019 100644 --- a/marketplace/src/pages/Cart.tsx +++ b/marketplace/src/pages/Cart.tsx @@ -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() { - Seguir para checkout unificado - + )} diff --git a/marketplace/src/pages/Checkout.tsx b/marketplace/src/pages/Checkout.tsx index 6ef891a..bd72d06 100644 --- a/marketplace/src/pages/Checkout.tsx +++ b/marketplace/src/pages/Checkout.tsx @@ -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) => { + 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 ( + +
+

Seu carrinho está vazio

+ +
+
+ ) + } return ( -
-
-
-
-

Checkout e Pagamento

-

Split automático por distribuidora, status pendente/pago.

-
-
-

Comprador

-

{user?.name}

-
-
- {Object.entries(groups).map(([vendorId, group]) => ( -
-
-

{group.vendorName}

-

R$ {formatCurrency(group.total)}

+
+ + +

Finalizar Compra

+ +
+ {/* Left Column: Form */} +
+
+
+ +

Endereço de Entrega

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Payment Method Stub */} +
+

Pagamento

+

Este é um ambiente de demonstração. O pagamento será processado como "Confirmado" para fins de teste.

+
+ + Método de Teste (Aprovação Automática)
-

Itens enviados no split de pagamento.

- ))} -
-
-
-

Resumo

-

R$ {formatCurrency(summary.totalValue)}

-
-

Status

-

{status ? status.toUpperCase() : 'Aguardando pagamento'}

-
-
-

Pagamento Mercado Pago

- { - setStatus('pendente') - }} - onReady={() => console.log('Payment brick pronto')} - onError={(error) => { - console.error(error) - setStatus(null) - }} - /> + + {/* Right Column: Summary */} +
+
+

Resumo do Pedido

+
+ {Object.entries(groups).map(([vendorId, group]) => ( +
+

{group.vendorName}

+ {group.items.map(item => ( +
+ {item.quantity}x {item.name} + R$ {formatCurrency(item.quantity * item.unitPrice)} +
+ ))} +
+ ))} + +
+
+ Total + R$ {formatCurrency(summary.totalValue)} +
+
+ + +
+
diff --git a/marketplace/src/pages/Orders.tsx b/marketplace/src/pages/Orders.tsx index c3d60cf..87ad146 100644 --- a/marketplace/src/pages/Orders.tsx +++ b/marketplace/src/pages/Orders.tsx @@ -98,8 +98,8 @@ export function OrdersPage() { - )} - {order.status === 'Pago' && ( - - )} - {order.status === 'Faturado' && ( - - )} - {order.status === 'Entregue' && ( - - ✅ Pedido concluído com sucesso - - )} +
+ {/* Timeline for Sales */} +
+
+ {['Pendente', 'Pago', 'Faturado', 'Entregue'].map((status, idx) => { + const isCompleted = ['Pendente', 'Pago', 'Faturado', 'Entregue'].indexOf(order.status) >= idx + return ( +
+
+ {isCompleted ? '✓' : idx + 1} +
+ {idx < 3 && ( +
idx + ? 'bg-blue-600' + : 'bg-gray-200' + }`} /> + )} +
+ ) + })} +
+
+ Pendente + Pago + Faturado + Entregue +
+
+ +
+ {order.status === 'Pendente' && ( + + )} + {order.status === 'Pago' && ( + + )} + {order.status === 'Faturado' && ( + + )} + {order.status === 'Entregue' && ( + + ✅ Pedido concluído com sucesso + + )} +
)} @@ -266,15 +299,15 @@ export function OrdersPage() { return (
{isCompleted ? '✓' : idx + 1}
{idx < 3 && (
idx - ? 'bg-blue-600' - : 'bg-gray-200' + ? 'bg-blue-600' + : 'bg-gray-200' }`} /> )}
@@ -287,6 +320,18 @@ export function OrdersPage() { Faturado Entregue
+ + {/* Confirm Receipt Action */} + {order.status === 'Faturado' && ( +
+ +
+ )}
)}
diff --git a/marketplace/src/pages/admin/ShippingSettings.tsx b/marketplace/src/pages/admin/ShippingSettings.tsx new file mode 100644 index 0000000..4803e31 --- /dev/null +++ b/marketplace/src/pages/admin/ShippingSettings.tsx @@ -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({ + 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
Carregando configurações de frete...
+ + return ( +
+

+ 🚚 + Configurações de Entrega e Retirada +

+ +
+ + {/* Delivery Settings */} +
+
+

+ 📍 + Entrega Própria +

+ +
+ +
+
+ + setSettings({ ...settings, max_radius_km: parseFloat(e.target.value) })} + /> +

Clique no mapa para definir o raio visualmente.

+
+ +
+
+ +
+ R$ + setSettings({ ...settings, price_per_km_cents: Math.round(parseFloat(e.target.value) * 100) })} + /> +
+
+
+ +
+ R$ + setSettings({ ...settings, min_fee_cents: Math.round(parseFloat(e.target.value) * 100) })} + /> +
+
+
+ +
+ +
+ R$ + setSettings({ ...settings, free_shipping_threshold_cents: Math.round(parseFloat(e.target.value) * 100) })} + /> +
+
+
+ + {/* MAP AREA */} +
+ {settings.latitude !== 0 && ( + + + + Sua Loja + + {settings.active && ( + + )} + {settings.active && ( + setSettings(s => ({ ...s, max_radius_km: r }))} + /> + )} + + )} +
+
+ + {/* Pickup Settings */} +
+
+

+ 🏪 + Retirada na Loja +

+ +
+ +
+
+ +